mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 04:00:14 +01:00
[GIT-66] improvement: prevent disabling last enabled authentication method (#8570)
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane internal packages
|
||||
import { setPromiseToast } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys } from "@plane/types";
|
||||
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
|
||||
import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { cn, resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import { PageWrapper } from "@/components/common/page-wrapper";
|
||||
// hooks
|
||||
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
|
||||
// helpers
|
||||
import { canDisableAuthMethod } from "@/helpers/authentication";
|
||||
// hooks
|
||||
import { useAuthenticationModes } from "@/hooks/oauth";
|
||||
import { useInstance } from "@/hooks/store";
|
||||
// types
|
||||
@@ -19,48 +21,87 @@ import type { Route } from "./+types/page";
|
||||
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
|
||||
// theme
|
||||
const { resolvedTheme: resolvedThemeAdmin } = useTheme();
|
||||
// store
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
|
||||
// Ref to store authentication modes for validation (avoids circular dependency)
|
||||
const authenticationModesRef = useRef<TInstanceAuthenticationModes[]>([]);
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
// store hooks
|
||||
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
|
||||
// derived values
|
||||
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
|
||||
const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => {
|
||||
setIsSubmitting(true);
|
||||
// Create updateConfig with validation - uses authenticationModesRef for current modes
|
||||
const updateConfig = useCallback(
|
||||
(key: TInstanceConfigurationKeys, value: string): void => {
|
||||
// Check if trying to disable (value === "0")
|
||||
if (value === "0") {
|
||||
// Check if this key is an authentication method key
|
||||
const currentAuthModes = authenticationModesRef.current;
|
||||
const isAuthMethodKey = currentAuthModes.some((method) => method.enabledConfigKey === key);
|
||||
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
// Only validate if this is an authentication method key
|
||||
if (isAuthMethodKey) {
|
||||
const canDisable = canDisableAuthMethod(key, currentAuthModes, formattedConfig);
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
if (!canDisable) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Cannot disable authentication",
|
||||
message:
|
||||
"At least one authentication method must remain enabled. Please enable another method before disabling this one.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving configuration",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => "Configuration saved successfully",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
// Proceed with the update
|
||||
setIsSubmitting(true);
|
||||
|
||||
await updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
const payload = {
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
const updateConfigPromise = updateInstanceConfigurations(payload);
|
||||
|
||||
setPromiseToast(updateConfigPromise, {
|
||||
loading: "Saving configuration",
|
||||
success: {
|
||||
title: "Success",
|
||||
message: () => "Configuration saved successfully",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
message: () => "Failed to save configuration",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const authenticationModes = useAuthenticationModes({ disabled: isSubmitting, updateConfig, resolvedTheme });
|
||||
void updateConfigPromise
|
||||
.then(() => {
|
||||
setIsSubmitting(false);
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
},
|
||||
[formattedConfig, updateInstanceConfigurations]
|
||||
);
|
||||
|
||||
// Get authentication modes - this will use updateConfig which includes validation
|
||||
const authenticationModes = useAuthenticationModes({
|
||||
disabled: isSubmitting,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
});
|
||||
|
||||
// Update ref with latest authentication modes
|
||||
authenticationModesRef.current = authenticationModes;
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { KeyRound, Mails } from "lucide-react";
|
||||
// plane packages
|
||||
import type { TAdminAuthErrorInfo } from "@plane/constants";
|
||||
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants";
|
||||
import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
|
||||
import { resolveGeneralTheme } from "@plane/utils";
|
||||
// components
|
||||
import githubLightModeImage from "@/app/assets/logos/github-black.png?url";
|
||||
import githubDarkModeImage from "@/app/assets/logos/github-white.png?url";
|
||||
import GitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url";
|
||||
import GoogleLogo from "@/app/assets/logos/google-logo.svg?url";
|
||||
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
|
||||
import { GithubConfiguration } from "@/components/authentication/github-config";
|
||||
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
|
||||
import { GoogleConfiguration } from "@/components/authentication/google-config";
|
||||
import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch";
|
||||
// images
|
||||
|
||||
export enum EErrorAlertType {
|
||||
BANNER_ALERT = "BANNER_ALERT",
|
||||
@@ -106,53 +92,3 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({
|
||||
disabled,
|
||||
updateConfig,
|
||||
resolvedTheme,
|
||||
}) => [
|
||||
{
|
||||
key: "unique-codes",
|
||||
name: "Unique codes",
|
||||
description:
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "passwords-login",
|
||||
name: "Passwords",
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "google",
|
||||
name: "Google",
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={GoogleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
description: "Allow members to log in or sign up for Plane with their GitHub accounts.",
|
||||
icon: (
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
{
|
||||
key: "gitlab",
|
||||
name: "GitLab",
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
},
|
||||
];
|
||||
|
||||
30
apps/admin/core/helpers/authentication.ts
Normal file
30
apps/admin/core/helpers/authentication.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
IFormattedInstanceConfiguration,
|
||||
TInstanceAuthenticationModes,
|
||||
TInstanceConfigurationKeys,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Checks if a given authentication method can be disabled.
|
||||
* @param configKey - The configuration key to check.
|
||||
* @param authModes - The authentication modes to check.
|
||||
* @param formattedConfig - The formatted configuration to check.
|
||||
* @returns True if the authentication method can be disabled, false otherwise.
|
||||
*/
|
||||
export const canDisableAuthMethod = (
|
||||
configKey: TInstanceConfigurationKeys,
|
||||
authModes: TInstanceAuthenticationModes[],
|
||||
formattedConfig: IFormattedInstanceConfiguration | undefined
|
||||
): boolean => {
|
||||
// Count currently enabled methods
|
||||
const enabledCount = authModes.reduce((count, method) => {
|
||||
const enabledKey = method.enabledConfigKey;
|
||||
if (!enabledKey || !formattedConfig) return count;
|
||||
const isEnabled = Boolean(parseInt(formattedConfig[enabledKey] ?? "0"));
|
||||
return isEnabled ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
// If trying to disable and only 1 method is enabled, prevent it
|
||||
const isCurrentlyEnabled = Boolean(parseInt(formattedConfig?.[configKey] ?? "0"));
|
||||
return !(isCurrentlyEnabled && enabledCount === 1);
|
||||
};
|
||||
@@ -34,6 +34,7 @@ export const getCoreAuthenticationModesMap: (
|
||||
"Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.",
|
||||
icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN",
|
||||
},
|
||||
"passwords-login": {
|
||||
key: "passwords-login",
|
||||
@@ -41,6 +42,7 @@ export const getCoreAuthenticationModesMap: (
|
||||
description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.",
|
||||
icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
|
||||
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
|
||||
},
|
||||
google: {
|
||||
key: "google",
|
||||
@@ -48,6 +50,7 @@ export const getCoreAuthenticationModesMap: (
|
||||
description: "Allow members to log in or sign up for Plane with their Google accounts.",
|
||||
icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
|
||||
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GOOGLE_ENABLED",
|
||||
},
|
||||
github: {
|
||||
key: "github",
|
||||
@@ -62,6 +65,7 @@ export const getCoreAuthenticationModesMap: (
|
||||
/>
|
||||
),
|
||||
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GITHUB_ENABLED",
|
||||
},
|
||||
gitlab: {
|
||||
key: "gitlab",
|
||||
@@ -69,6 +73,7 @@ export const getCoreAuthenticationModesMap: (
|
||||
description: "Allow members to log in or sign up to plane with their GitLab accounts.",
|
||||
icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
|
||||
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GITLAB_ENABLED",
|
||||
},
|
||||
gitea: {
|
||||
key: "gitea",
|
||||
@@ -76,5 +81,6 @@ export const getCoreAuthenticationModesMap: (
|
||||
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
|
||||
icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
|
||||
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
|
||||
enabledConfigKey: "IS_GITEA_ENABLED",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
} from "@/helpers/authentication.helper";
|
||||
// hooks
|
||||
import { useOAuthConfig } from "@/hooks/oauth";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
// local imports
|
||||
import { TermsAndConditions } from "../terms-and-conditions";
|
||||
import { AuthBanner } from "./auth-banner";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import { AuthHeader, AuthHeaderBase } from "./auth-header";
|
||||
import { AuthFormRoot } from "./form-root";
|
||||
|
||||
type TAuthRoot = {
|
||||
@@ -39,9 +40,13 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
|
||||
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
|
||||
const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
|
||||
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
|
||||
// store hooks
|
||||
const { config } = useInstance();
|
||||
// derived values
|
||||
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
|
||||
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
|
||||
const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled;
|
||||
const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!authMode && currentAuthMode) setAuthMode(currentAuthMode);
|
||||
@@ -91,22 +96,37 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
|
||||
|
||||
if (!authMode) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={authMode}
|
||||
currentAuthStep={authStep}
|
||||
if (noAuthMethodsAvailable) {
|
||||
return (
|
||||
<AuthContainer>
|
||||
<AuthHeaderBase
|
||||
header="No authentication methods available"
|
||||
subHeader="Please contact your administrator to enable authentication for your instance."
|
||||
/>
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
|
||||
{isOAuthEnabled && <OAuthOptions options={oAuthOptions} compact={authStep === EAuthSteps.PASSWORD} />}
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && (
|
||||
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} />
|
||||
)}
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={authMode}
|
||||
currentAuthStep={authStep}
|
||||
/>
|
||||
{isOAuthEnabled && (
|
||||
<OAuthOptions
|
||||
options={oAuthOptions}
|
||||
compact={authStep === EAuthSteps.PASSWORD}
|
||||
showDivider={isEmailBasedAuthEnabled}
|
||||
/>
|
||||
)}
|
||||
{isEmailBasedAuthEnabled && (
|
||||
<AuthFormRoot
|
||||
authStep={authStep}
|
||||
authMode={authMode}
|
||||
@@ -117,8 +137,16 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
|
||||
setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)}
|
||||
currentAuthMode={currentAuthMode}
|
||||
/>
|
||||
<TermsAndConditions authType={authMode} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TermsAndConditions authType={authMode} />
|
||||
</AuthContainer>
|
||||
);
|
||||
});
|
||||
|
||||
function AuthContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { TExtendedInstanceAuthenticationModeKeys } from "./auth-ee";
|
||||
|
||||
export type TCoreInstanceAuthenticationModeKeys =
|
||||
| "unique-codes"
|
||||
| "passwords-login"
|
||||
@@ -8,9 +6,7 @@ export type TCoreInstanceAuthenticationModeKeys =
|
||||
| "gitlab"
|
||||
| "gitea";
|
||||
|
||||
export type TInstanceAuthenticationModeKeys =
|
||||
| TCoreInstanceAuthenticationModeKeys
|
||||
| TExtendedInstanceAuthenticationModeKeys;
|
||||
export type TInstanceAuthenticationModeKeys = TCoreInstanceAuthenticationModeKeys;
|
||||
|
||||
export type TInstanceAuthenticationModes = {
|
||||
key: TInstanceAuthenticationModeKeys;
|
||||
@@ -18,6 +14,7 @@ export type TInstanceAuthenticationModes = {
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
config: React.ReactNode;
|
||||
enabledConfigKey: TInstanceAuthenticationMethodKeys;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ export type TOAuthOption = {
|
||||
type OAuthOptionsProps = {
|
||||
options: TOAuthOption[];
|
||||
compact?: boolean;
|
||||
|
||||
showDivider?: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export function OAuthOptions(props: OAuthOptionsProps) {
|
||||
const { options, compact = false, className = "", containerClassName = "" } = props;
|
||||
const { options, compact = false, showDivider = true, className = "", containerClassName = "" } = props;
|
||||
|
||||
// Filter enabled options
|
||||
const enabledOptions = options.filter((option) => option.enabled !== false);
|
||||
@@ -47,11 +47,13 @@ export function OAuthOptions(props: OAuthOptionsProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center transition-all duration-300">
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
<p className="mx-3 flex-shrink-0 text-center text-13 text-placeholder transition-colors duration-300">or</p>
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
</div>
|
||||
{showDivider && (
|
||||
<div className="mt-4 flex items-center transition-all duration-300">
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
<p className="mx-3 flex-shrink-0 text-center text-13 text-placeholder transition-colors duration-300">or</p>
|
||||
<hr className="w-full border-strong transition-colors duration-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user