[GIT-66] improvement: prevent disabling last enabled authentication method (#8570)

This commit is contained in:
Prateek Shourya 2026-01-27 00:47:37 +05:30 committed by GitHub
parent f7d5200ed8
commit 32a2584578
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 167 additions and 127 deletions

View file

@ -1,16 +1,18 @@
import { useState } from "react"; import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import useSWR from "swr"; import useSWR from "swr";
// plane internal packages // plane internal packages
import { setPromiseToast } from "@plane/propel/toast"; import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { TInstanceConfigurationKeys } from "@plane/types"; import type { TInstanceConfigurationKeys, TInstanceAuthenticationModes } from "@plane/types";
import { Loader, ToggleSwitch } from "@plane/ui"; import { Loader, ToggleSwitch } from "@plane/ui";
import { cn, resolveGeneralTheme } from "@plane/utils"; import { cn, resolveGeneralTheme } from "@plane/utils";
// components // components
import { PageWrapper } from "@/components/common/page-wrapper"; import { PageWrapper } from "@/components/common/page-wrapper";
// hooks
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// helpers
import { canDisableAuthMethod } from "@/helpers/authentication";
// hooks
import { useAuthenticationModes } from "@/hooks/oauth"; import { useAuthenticationModes } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store"; import { useInstance } from "@/hooks/store";
// types // types
@ -19,48 +21,87 @@ import type { Route } from "./+types/page";
const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) { const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(_props: Route.ComponentProps) {
// theme // theme
const { resolvedTheme: resolvedThemeAdmin } = useTheme(); const { resolvedTheme: resolvedThemeAdmin } = useTheme();
// store const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); // Ref to store authentication modes for validation (avoids circular dependency)
const authenticationModesRef = useRef<TInstanceAuthenticationModes[]>([]);
// state // state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// store hooks
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// derived values // derived values
const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? "";
const resolvedTheme = resolveGeneralTheme(resolvedThemeAdmin);
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { // Create updateConfig with validation - uses authenticationModesRef for current modes
setIsSubmitting(true); 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 = { // Only validate if this is an authentication method key
[key]: value, 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, { // Proceed with the update
loading: "Saving configuration", setIsSubmitting(true);
success: {
title: "Success",
message: () => "Configuration saved successfully",
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise const payload = {
.then(() => { [key]: value,
setIsSubmitting(false); };
})
.catch((err) => { const updateConfigPromise = updateInstanceConfigurations(payload);
console.error(err);
setIsSubmitting(false); 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 ( return (
<PageWrapper <PageWrapper
header={{ header={{

View file

@ -1,21 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages // plane packages
import type { TAdminAuthErrorInfo } from "@plane/constants"; import type { TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes } 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 { export enum EErrorAlertType {
BANNER_ALERT = "BANNER_ALERT", BANNER_ALERT = "BANNER_ALERT",
@ -106,53 +92,3 @@ export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string
return undefined; 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} />,
},
];

View 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);
};

View file

@ -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.", "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" />, icon: <Mails className="h-6 w-6 p-0.5 text-tertiary" />,
config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <EmailCodesConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_MAGIC_LINK_LOGIN",
}, },
"passwords-login": { "passwords-login": {
key: "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.", 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" />, icon: <KeyRound className="h-6 w-6 p-0.5 text-tertiary" />,
config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <PasswordLoginConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
}, },
google: { google: {
key: "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.", 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" />, icon: <img src={googleLogo} height={20} width={20} alt="Google Logo" />,
config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GoogleConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GOOGLE_ENABLED",
}, },
github: { github: {
key: "github", key: "github",
@ -62,6 +65,7 @@ export const getCoreAuthenticationModesMap: (
/> />
), ),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITHUB_ENABLED",
}, },
gitlab: { gitlab: {
key: "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.", 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" />, icon: <img src={gitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITLAB_ENABLED",
}, },
gitea: { gitea: {
key: "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.", 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" />, icon: <img src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />, config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITEA_ENABLED",
}, },
}); });

View file

@ -14,10 +14,11 @@ import {
} from "@/helpers/authentication.helper"; } from "@/helpers/authentication.helper";
// hooks // hooks
import { useOAuthConfig } from "@/hooks/oauth"; import { useOAuthConfig } from "@/hooks/oauth";
import { useInstance } from "@/hooks/store/use-instance";
// local imports // local imports
import { TermsAndConditions } from "../terms-and-conditions"; import { TermsAndConditions } from "../terms-and-conditions";
import { AuthBanner } from "./auth-banner"; import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header"; import { AuthHeader, AuthHeaderBase } from "./auth-header";
import { AuthFormRoot } from "./form-root"; import { AuthFormRoot } from "./form-root";
type TAuthRoot = { type TAuthRoot = {
@ -39,9 +40,13 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL); const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); const [email, setEmail] = useState(emailParam ? emailParam.toString() : "");
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined); const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
// store hooks
const { config } = useInstance();
// derived values // derived values
const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in"; const oAuthActionText = authMode === EAuthModes.SIGN_UP ? "Sign up" : "Sign in";
const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText); const { isOAuthEnabled, oAuthOptions } = useOAuthConfig(oAuthActionText);
const isEmailBasedAuthEnabled = config?.is_email_password_enabled || config?.is_magic_login_enabled;
const noAuthMethodsAvailable = !isOAuthEnabled && !isEmailBasedAuthEnabled;
useEffect(() => { useEffect(() => {
if (!authMode && currentAuthMode) setAuthMode(currentAuthMode); if (!authMode && currentAuthMode) setAuthMode(currentAuthMode);
@ -91,22 +96,37 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
if (!authMode) return <></>; if (!authMode) return <></>;
return ( if (noAuthMethodsAvailable) {
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10"> return (
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full"> <AuthContainer>
{errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( <AuthHeaderBase
<AuthBanner message={errorInfo.message} handleBannerData={(value) => setErrorInfo(value)} /> header="No authentication methods available"
)} subHeader="Please contact your administrator to enable authentication for your instance."
<AuthHeader
workspaceSlug={workspaceSlug?.toString() || undefined}
invitationId={invitation_id?.toString() || undefined}
invitationEmail={email || undefined}
authMode={authMode}
currentAuthStep={authStep}
/> />
</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 <AuthFormRoot
authStep={authStep} authStep={authStep}
authMode={authMode} authMode={authMode}
@ -117,8 +137,16 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)} setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)}
currentAuthMode={currentAuthMode} currentAuthMode={currentAuthMode}
/> />
<TermsAndConditions authType={authMode} /> )}
</div> <TermsAndConditions authType={authMode} />
</div> </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>
);
}

View file

@ -1,5 +1,3 @@
import type { TExtendedInstanceAuthenticationModeKeys } from "./auth-ee";
export type TCoreInstanceAuthenticationModeKeys = export type TCoreInstanceAuthenticationModeKeys =
| "unique-codes" | "unique-codes"
| "passwords-login" | "passwords-login"
@ -8,9 +6,7 @@ export type TCoreInstanceAuthenticationModeKeys =
| "gitlab" | "gitlab"
| "gitea"; | "gitea";
export type TInstanceAuthenticationModeKeys = export type TInstanceAuthenticationModeKeys = TCoreInstanceAuthenticationModeKeys;
| TCoreInstanceAuthenticationModeKeys
| TExtendedInstanceAuthenticationModeKeys;
export type TInstanceAuthenticationModes = { export type TInstanceAuthenticationModes = {
key: TInstanceAuthenticationModeKeys; key: TInstanceAuthenticationModeKeys;
@ -18,6 +14,7 @@ export type TInstanceAuthenticationModes = {
description: string; description: string;
icon: React.ReactNode; icon: React.ReactNode;
config: React.ReactNode; config: React.ReactNode;
enabledConfigKey: TInstanceAuthenticationMethodKeys;
unavailable?: boolean; unavailable?: boolean;
}; };

View file

@ -13,13 +13,13 @@ export type TOAuthOption = {
type OAuthOptionsProps = { type OAuthOptionsProps = {
options: TOAuthOption[]; options: TOAuthOption[];
compact?: boolean; compact?: boolean;
showDivider?: boolean;
className?: string; className?: string;
containerClassName?: string; containerClassName?: string;
}; };
export function OAuthOptions(props: OAuthOptionsProps) { export function OAuthOptions(props: OAuthOptionsProps) {
const { options, compact = false, className = "", containerClassName = "" } = props; const { options, compact = false, showDivider = true, className = "", containerClassName = "" } = props;
// Filter enabled options // Filter enabled options
const enabledOptions = options.filter((option) => option.enabled !== false); const enabledOptions = options.filter((option) => option.enabled !== false);
@ -47,11 +47,13 @@ export function OAuthOptions(props: OAuthOptionsProps) {
))} ))}
</div> </div>
<div className="mt-4 flex items-center transition-all duration-300"> {showDivider && (
<hr className="w-full border-strong transition-colors duration-300" /> <div className="mt-4 flex items-center transition-all 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" />
<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>
</div> <hr className="w-full border-strong transition-colors duration-300" />
</div>
)}
</div> </div>
); );
} }