Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 74 additions & 33 deletions apps/admin/app/(all)/(dashboard)/authentication/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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={{
Expand Down
64 changes: 0 additions & 64 deletions apps/admin/app/(all)/(home)/auth-helpers.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 changes: 30 additions & 0 deletions apps/admin/core/helpers/authentication.ts
Original file line number Diff line number Diff line change
@@ -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);
};
6 changes: 6 additions & 0 deletions apps/admin/core/hooks/oauth/core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,23 @@ 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",
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} />,
enabledConfigKey: "ENABLE_EMAIL_PASSWORD",
},
google: {
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} />,
enabledConfigKey: "IS_GOOGLE_ENABLED",
},
github: {
key: "github",
Expand All @@ -62,19 +65,22 @@ export const getCoreAuthenticationModesMap: (
/>
),
config: <GithubConfiguration disabled={disabled} updateConfig={updateConfig} />,
enabledConfigKey: "IS_GITHUB_ENABLED",
},
gitlab: {
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} />,
enabledConfigKey: "IS_GITLAB_ENABLED",
},
gitea: {
key: "gitea",
name: "Gitea",
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",
},
});
64 changes: 46 additions & 18 deletions apps/web/core/components/account/auth-forms/auth-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -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}
Expand All @@ -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>
);
}
Loading
Loading