diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx index 10adc312974..f4b767d3250 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -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([]); // state const [isSubmitting, setIsSubmitting] = useState(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 ( 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: , - config: , - }, - { - key: "passwords-login", - name: "Passwords", - description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", - icon: , - config: , - }, - { - key: "google", - name: "Google", - description: "Allow members to log in or sign up for Plane with their Google accounts.", - icon: Google Logo, - config: , - }, - { - key: "github", - name: "GitHub", - description: "Allow members to log in or sign up for Plane with their GitHub accounts.", - icon: ( - GitHub Logo - ), - config: , - }, - { - key: "gitlab", - name: "GitLab", - description: "Allow members to log in or sign up to plane with their GitLab accounts.", - icon: GitLab Logo, - config: , - }, -]; diff --git a/apps/admin/core/helpers/authentication.ts b/apps/admin/core/helpers/authentication.ts new file mode 100644 index 00000000000..69677e49570 --- /dev/null +++ b/apps/admin/core/helpers/authentication.ts @@ -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); +}; diff --git a/apps/admin/core/hooks/oauth/core.tsx b/apps/admin/core/hooks/oauth/core.tsx index dc04b40ff70..0a1907b6626 100644 --- a/apps/admin/core/hooks/oauth/core.tsx +++ b/apps/admin/core/hooks/oauth/core.tsx @@ -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: , config: , + 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: , config: , + 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: Google Logo, config: , + enabledConfigKey: "IS_GOOGLE_ENABLED", }, github: { key: "github", @@ -62,6 +65,7 @@ export const getCoreAuthenticationModesMap: ( /> ), config: , + 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: GitLab Logo, config: , + 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: Gitea Logo, config: , + enabledConfigKey: "IS_GITEA_ENABLED", }, }); diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx index 83569611485..f50d1af2bee 100644 --- a/apps/web/core/components/account/auth-forms/auth-root.tsx +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -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.EMAIL); const [email, setEmail] = useState(emailParam ? emailParam.toString() : ""); const [errorInfo, setErrorInfo] = useState(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 ( -
-
- {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( - setErrorInfo(value)} /> - )} - + + + ); + } - {isOAuthEnabled && } - + return ( + + {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} + + {isOAuthEnabled && ( + + )} + {isEmailBasedAuthEnabled && ( setErrorInfo(errorInfo)} currentAuthMode={currentAuthMode} /> - -
-
+ )} + + ); }); + +function AuthContainer({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/packages/types/src/instance/auth.ts b/packages/types/src/instance/auth.ts index de0612a3f4a..fa9f444d394 100644 --- a/packages/types/src/instance/auth.ts +++ b/packages/types/src/instance/auth.ts @@ -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; }; diff --git a/packages/ui/src/oauth/oauth-options.tsx b/packages/ui/src/oauth/oauth-options.tsx index 4eddb4b1861..9796f23ebb0 100644 --- a/packages/ui/src/oauth/oauth-options.tsx +++ b/packages/ui/src/oauth/oauth-options.tsx @@ -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) { ))} -
-
-

or

-
-
+ {showDivider && ( +
+
+

or

+
+
+ )} ); }