From 564da949cb87491ca8e493d2f7aedcd2ef9545a1 Mon Sep 17 00:00:00 2001 From: boojack Date: Mon, 13 Jul 2026 19:40:31 +0800 Subject: [PATCH] chore(auth): redesign sign-in and sign-up pages with panel layout Rebuild the auth pages around a shared AuthPageLayout card with a three-tier guidance hierarchy: the form (or provider buttons) on top, a centered switch sentence below it, and a quiet explore band anchored to the card foot on public instances. - add AuthPageLayout (brand row, chip, empty state, link prompt, explore band gated by instance URL with hideExplore opt-out) - extract CredentialFields shared by sign-in and sign-up forms - extract IdentityProviderButtons; fetch the provider list via a cached TanStack Query hook with an enabled flag - cover every instance config: SSO-only, password-only, registrations closed, private instance, nothing enabled, first-run setup, admin - promote /auth/signup, /auth/admin, /auth/callback to ROUTES and add appendSearchParams for param-preserving auth links - move the explore link out of AuthFooter (selectors only now) - refresh en and zh-Hans auth strings; drop dead keys --- web/src/components/AuthPageLayout.tsx | 90 +++++++++ web/src/components/CredentialFields.tsx | 55 ++++++ .../components/IdentityProviderButtons.tsx | 67 +++++++ web/src/components/PasswordSignInForm.tsx | 70 ++----- web/src/hooks/useIdentityProviderQueries.ts | 23 +++ web/src/locales/en.json | 22 ++- web/src/locales/zh-Hans.json | 22 ++- web/src/pages/AdminSignIn.tsx | 38 ++-- web/src/pages/SignIn.tsx | 140 ++++---------- web/src/pages/SignUp.tsx | 179 +++++++++--------- web/src/router/routes.ts | 3 + web/src/utils/auth-redirect.ts | 1 + web/src/utils/redirect-safety.ts | 9 + web/tests/auth-page-layout.test.tsx | 52 +++++ 14 files changed, 511 insertions(+), 260 deletions(-) create mode 100644 web/src/components/AuthPageLayout.tsx create mode 100644 web/src/components/CredentialFields.tsx create mode 100644 web/src/components/IdentityProviderButtons.tsx create mode 100644 web/src/hooks/useIdentityProviderQueries.ts create mode 100644 web/tests/auth-page-layout.test.tsx diff --git a/web/src/components/AuthPageLayout.tsx b/web/src/components/AuthPageLayout.tsx new file mode 100644 index 00000000..85cffb0d --- /dev/null +++ b/web/src/components/AuthPageLayout.tsx @@ -0,0 +1,90 @@ +import { ArrowRightIcon, CompassIcon } from "lucide-react"; +import { Link } from "react-router-dom"; +import { useInstance } from "@/contexts/InstanceContext"; +import { ROUTES } from "@/router/routes"; +import { useTranslate } from "@/utils/i18n"; +import AuthFooter from "./AuthFooter"; + +interface Props { + chip?: React.ReactNode; + title: string; + subtitle?: string; + // Hide the explore band on pages that shouldn't offer an exit (e.g. first-run setup). + hideExplore?: boolean; + children: React.ReactNode; +} + +export const AuthChip = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +// Centered icon + title + description block for states where a form cannot be shown. +export const AuthEmptyState = ({ + icon, + title, + description, + children, +}: { + icon: React.ReactNode; + title: string; + description: string; + children?: React.ReactNode; +}) => ( +
+
{icon}
+

{title}

+

{description}

+ {children} +
+); + +// "Already have an account? Sign in" style prompt for hopping between auth pages. +export const AuthLinkPrompt = ({ prompt, to, label }: { prompt: string; to: string; label: string }) => ( +

+ {prompt}{" "} + + {label} + +

+); + +const AuthPageLayout = ({ chip, title, subtitle, hideExplore, children }: Props) => { + const t = useTranslate(); + const { generalSetting, profile } = useInstance(); + const showExplore = Boolean(profile.instanceUrl) && !hideExplore; + + return ( +
+
+
+
+ + {generalSetting.customProfile?.title || "Memos"} +
+ {chip &&
{chip}
} +

{title}

+ {subtitle &&

{subtitle}

} +
{children}
+ {showExplore && ( +
+ + + {t("auth.explore-public-memos")} + + +
+ )} +
+
+ +
+ ); +}; + +export default AuthPageLayout; diff --git a/web/src/components/CredentialFields.tsx b/web/src/components/CredentialFields.tsx new file mode 100644 index 00000000..b79bb228 --- /dev/null +++ b/web/src/components/CredentialFields.tsx @@ -0,0 +1,55 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTranslate } from "@/utils/i18n"; + +interface Props { + idPrefix: string; + username: string; + password: string; + passwordAutoComplete: "current-password" | "new-password"; + readOnly?: boolean; + onUsernameChange: (username: string) => void; + onPasswordChange: (password: string) => void; +} + +// Username + password field pair shared by the sign-in and sign-up forms. +const CredentialFields = ({ idPrefix, username, password, passwordAutoComplete, readOnly, onUsernameChange, onPasswordChange }: Props) => { + const t = useTranslate(); + + return ( + <> +
+ + onUsernameChange(e.target.value)} + required + /> +
+
+ + onPasswordChange(e.target.value)} + required + /> +
+ + ); +}; + +export default CredentialFields; diff --git a/web/src/components/IdentityProviderButtons.tsx b/web/src/components/IdentityProviderButtons.tsx new file mode 100644 index 00000000..d0e44971 --- /dev/null +++ b/web/src/components/IdentityProviderButtons.tsx @@ -0,0 +1,67 @@ +import { toast } from "react-hot-toast"; +import { Button } from "@/components/ui/button"; +import { absolutifyLink } from "@/lib/browser"; +import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; +import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; +import { useTranslate } from "@/utils/i18n"; +import { storeOAuthState } from "@/utils/oauth"; + +interface Props { + identityProviderList: IdentityProvider[]; + redirectTarget?: string; +} + +const IdentityProviderButtons = ({ identityProviderList, redirectTarget }: Props) => { + const t = useTranslate(); + + const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => { + if (identityProvider.type === IdentityProvider_Type.OAUTH2) { + const redirectUri = absolutifyLink(ROUTES.AUTH_CALLBACK); + const oauth2Config = identityProvider.config?.config?.case === "oauth2Config" ? identityProvider.config.config.value : undefined; + if (!oauth2Config) { + toast.error("Identity provider configuration is invalid."); + return; + } + + try { + // Generate and store secure state parameter with CSRF protection + // Also generate PKCE parameters (code_challenge) for enhanced security if available + const { state, codeChallenge } = await storeOAuthState(identityProvider.name, "signin", redirectTarget); + + // Build OAuth authorization URL with secure state + // Include PKCE if available (requires HTTPS/localhost for crypto.subtle) + // Using S256 (SHA-256) as the code_challenge_method per RFC 7636 + let authUrl = `${oauth2Config.authUrl}?client_id=${ + oauth2Config.clientId + }&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&response_type=code&scope=${encodeURIComponent( + oauth2Config.scopes.join(" "), + )}`; + + // Add PKCE parameters if available + if (codeChallenge) { + authUrl += `&code_challenge=${codeChallenge}&code_challenge_method=S256`; + } + + window.location.href = authUrl; + } catch (error) { + handleError(error, toast.error, { + context: "Failed to initiate OAuth flow", + fallbackMessage: "Failed to initiate sign-in. Please try again.", + }); + } + } + }; + + return ( +
+ {identityProviderList.map((identityProvider) => ( + + ))} +
+ ); +}; + +export default IdentityProviderButtons; diff --git a/web/src/components/PasswordSignInForm.tsx b/web/src/components/PasswordSignInForm.tsx index 9902f84f..9e449707 100644 --- a/web/src/components/PasswordSignInForm.tsx +++ b/web/src/components/PasswordSignInForm.tsx @@ -3,8 +3,8 @@ import { LoaderIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "react-hot-toast"; import { setAccessToken } from "@/auth-state"; +import CredentialFields from "@/components/CredentialFields"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { authServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import useLoading from "@/hooks/useLoading"; @@ -25,22 +25,9 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const handleUsernameInputChanged = (e: React.ChangeEvent) => { - const text = e.target.value as string; - setUsername(text); - }; - - const handlePasswordInputChanged = (e: React.ChangeEvent) => { - const text = e.target.value as string; - setPassword(text); - }; - - const handleFormSubmit = (e: React.FormEvent) => { + const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); - handleSignInButtonClick(); - }; - const handleSignInButtonClick = async () => { if (username === "" || password === "") { return; } @@ -72,45 +59,20 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) { }; return ( -
-
-
- {t("common.username")} - -
-
- {t("common.password")} - -
-
-
- -
+ + + ); } diff --git a/web/src/hooks/useIdentityProviderQueries.ts b/web/src/hooks/useIdentityProviderQueries.ts new file mode 100644 index 00000000..d422250e --- /dev/null +++ b/web/src/hooks/useIdentityProviderQueries.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import { identityProviderServiceClient } from "@/connect"; +import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; + +// Query keys factory +export const identityProviderKeys = { + all: ["identityProviders"] as const, + list: () => [...identityProviderKeys.all, "list"] as const, +}; + +const EMPTY_LIST: IdentityProvider[] = []; + +// Hook to fetch the configured identity providers. Pass `enabled: false` on +// pages/branches that never render provider buttons to skip the request. +export function useIdentityProviderList(enabled = true): IdentityProvider[] { + const { data } = useQuery({ + queryKey: identityProviderKeys.list(), + queryFn: async () => (await identityProviderServiceClient.listIdentityProviders({})).identityProviders, + staleTime: 60_000, + enabled, + }); + return data ?? EMPTY_LIST; +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index ea3cf7f2..953a8ccd 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -8,13 +8,30 @@ "official-website": "Official Website" }, "auth": { + "admin-sign-in": "Administrator sign-in", + "admin-sign-in-tip": "Password sign-in stays available for administrators.", + "back-to-sign-in": "Back to sign in", + "continue-with": "Continue with {{provider}}", + "create-admin-account": "Create admin account", "create-your-account": "Create your account", - "host-tip": "You are registering as the Site Host.", + "explore-public-memos": "Explore public memos on this instance", + "first-run": "First run", "new-password": "New password", "protected-memo-notice": "This memo is not public. Sign in to continue.", "repeat-new-password": "Repeat the new password", + "setup-description": "Create the administrator account for this instance.", + "setup-note": "This account owns instance settings, members, and identity providers.", + "setup-title": "Set up your instance", "sign-in-tip": "Already have an account?", - "sign-up-tip": "Don't have an account yet?" + "sign-up-tip": "Don't have an account yet?", + "signin-unavailable-description": "Password sign-in is turned off and no identity providers are configured. Ask your administrator to enable a sign-in method.", + "signin-unavailable-title": "Sign-in is unavailable", + "signup-unavailable-description": "Password accounts are turned off and no identity providers are configured. Ask your administrator for access.", + "signup-unavailable-title": "Sign-up is unavailable", + "signups-closed-description": "New accounts are disabled on this instance. Contact your administrator if you need access.", + "signups-closed-title": "Sign-ups are closed", + "sso-signup-tip": "Sign in with a provider below to create your account.", + "welcome-back": "Welcome back." }, "demo": { "banner-description": "Changes are temporary and may be reset.", @@ -137,7 +154,6 @@ "shortcut-filter": "Shortcut filter", "shortcuts": "Shortcuts", "sign-in": "Sign in", - "sign-in-with": "Sign in with {{provider}}", "sign-out": "Sign out", "sign-up": "Sign up", "statistics": "Statistics", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 28c0bb4e..18746c28 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -8,13 +8,30 @@ "media": "媒体" }, "auth": { + "admin-sign-in": "管理员登录", + "admin-sign-in-tip": "管理员始终可以使用密码登录。", + "back-to-sign-in": "返回登录", + "continue-with": "使用 {{provider}} 继续", + "create-admin-account": "创建管理员账号", "create-your-account": "创建您的账户", - "host-tip": "您正在注册为站点管理员。", + "explore-public-memos": "浏览此实例上的公开 Memo", + "first-run": "首次运行", "new-password": "新密码", "protected-memo-notice": "此备忘录不是公开的。请先登录后继续。", "repeat-new-password": "重复新密码", + "setup-description": "为此实例创建管理员账号。", + "setup-note": "该账号拥有实例设置、成员与身份提供商的管理权限。", + "setup-title": "初始化实例", "sign-in-tip": "已有账户?", - "sign-up-tip": "还没有账户?" + "sign-up-tip": "还没有账户?", + "signin-unavailable-description": "密码登录已关闭,且未配置身份提供商。请联系管理员启用登录方式。", + "signin-unavailable-title": "无法登录", + "signup-unavailable-description": "密码账号已关闭,且未配置身份提供商。如需访问,请联系管理员。", + "signup-unavailable-title": "无法注册", + "signups-closed-description": "此实例已禁用新账号注册。如需访问,请联系管理员。", + "signups-closed-title": "注册已关闭", + "sso-signup-tip": "使用以下登录方式即可创建账号。", + "welcome-back": "欢迎回来。" }, "demo": { "banner-description": "这里的改动是临时的,可能会被重置。", @@ -105,7 +122,6 @@ "shortcut-filter": "捷径过滤器", "shortcuts": "捷径", "sign-in": "登录", - "sign-in-with": "使用 {{provider}} 登录", "sign-out": "退出登录", "sign-up": "注册", "statistics": "统计", diff --git a/web/src/pages/AdminSignIn.tsx b/web/src/pages/AdminSignIn.tsx index 377fa56f..56ee5599 100644 --- a/web/src/pages/AdminSignIn.tsx +++ b/web/src/pages/AdminSignIn.tsx @@ -1,22 +1,32 @@ -import AuthFooter from "@/components/AuthFooter"; +import { ArrowLeftIcon, ShieldCheckIcon } from "lucide-react"; +import { Link } from "react-router-dom"; +import AuthPageLayout, { AuthChip } from "@/components/AuthPageLayout"; import PasswordSignInForm from "@/components/PasswordSignInForm"; -import { useInstance } from "@/contexts/InstanceContext"; +import { ROUTES } from "@/router/routes"; +import { useTranslate } from "@/utils/i18n"; const AdminSignIn = () => { - const { generalSetting: instanceGeneralSetting } = useInstance(); + const t = useTranslate(); return ( -
-
-
- -

{instanceGeneralSetting.customProfile?.title || "Memos"}

-
-

Sign in with admin accounts

- -
- -
+ + + {t("common.admin")} + + } + title={t("auth.admin-sign-in")} + subtitle={t("auth.admin-sign-in-tip")} + > + +

+ + + {t("auth.back-to-sign-in")} + +

+
); }; diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index cd198691..52b2ea1d 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -1,122 +1,62 @@ -import { useEffect, useState } from "react"; -import { toast } from "react-hot-toast"; +import { ArrowRightIcon, LockIcon } from "lucide-react"; import { Link, useSearchParams } from "react-router-dom"; -import AuthFooter from "@/components/AuthFooter"; +import AuthPageLayout, { AuthEmptyState, AuthLinkPrompt } from "@/components/AuthPageLayout"; +import IdentityProviderButtons from "@/components/IdentityProviderButtons"; import PasswordSignInForm from "@/components/PasswordSignInForm"; -import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { identityProviderServiceClient } from "@/connect"; import { useInstance } from "@/contexts/InstanceContext"; -import { absolutifyLink } from "@/lib/browser"; -import { handleError } from "@/lib/error"; +import { useIdentityProviderList } from "@/hooks/useIdentityProviderQueries"; import { ROUTES } from "@/router/routes"; -import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; -import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, appendSearchParams, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; -import { storeOAuthState } from "@/utils/oauth"; const SignIn = () => { const t = useTranslate(); - const [identityProviderList, setIdentityProviderList] = useState([]); const { generalSetting: instanceGeneralSetting } = useInstance(); const [searchParams] = useSearchParams(); + const identityProviderList = useIdentityProviderList(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; + const signUpPath = appendSearchParams(ROUTES.AUTH_SIGNUP, searchParams); - // Prepare identity provider list. - useEffect(() => { - const fetchIdentityProviderList = async () => { - const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({}); - setIdentityProviderList(identityProviders); - }; - fetchIdentityProviderList(); - }, []); + const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth; + const hasIdentityProviders = identityProviderList.length > 0; - const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => { - if (identityProvider.type === IdentityProvider_Type.OAUTH2) { - const redirectUri = absolutifyLink("/auth/callback"); - const oauth2Config = identityProvider.config?.config?.case === "oauth2Config" ? identityProvider.config.config.value : undefined; - if (!oauth2Config) { - toast.error("Identity provider configuration is invalid."); - return; - } - - try { - // Generate and store secure state parameter with CSRF protection - // Also generate PKCE parameters (code_challenge) for enhanced security if available - const { state, codeChallenge } = await storeOAuthState(identityProvider.name, "signin", redirectTarget); - - // Build OAuth authorization URL with secure state - // Include PKCE if available (requires HTTPS/localhost for crypto.subtle) - // Using S256 (SHA-256) as the code_challenge_method per RFC 7636 - let authUrl = `${oauth2Config.authUrl}?client_id=${ - oauth2Config.clientId - }&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&response_type=code&scope=${encodeURIComponent( - oauth2Config.scopes.join(" "), - )}`; - - // Add PKCE parameters if available - if (codeChallenge) { - authUrl += `&code_challenge=${codeChallenge}&code_challenge_method=S256`; - } - - window.location.href = authUrl; - } catch (error) { - handleError(error, toast.error, { - context: "Failed to initiate OAuth flow", - fallbackMessage: "Failed to initiate sign-in. Please try again.", - }); - } - } - }; + const subtitle = passwordAuthAllowed || hasIdentityProviders ? t("auth.welcome-back") : undefined; return ( -
-
-
- -

{instanceGeneralSetting.customProfile?.title || "Memos"}

-
- {!instanceGeneralSetting.disallowPasswordAuth ? ( - - ) : ( - identityProviderList.length === 0 &&

Password auth is not allowed.

- )} - {!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && ( -

- {t("auth.sign-up-tip")} - - {t("common.sign-up")} - -

- )} - {identityProviderList.length > 0 && ( - <> - {!instanceGeneralSetting.disallowPasswordAuth && ( -
+ + {passwordAuthAllowed || hasIdentityProviders ? ( + <> + {hasIdentityProviders && } + {hasIdentityProviders && passwordAuthAllowed && ( +
+
+ +
+ {t("common.or")} +
-
- {t("common.or")} -
- )} -
- {identityProviderList.map((identityProvider) => ( - - ))}
- - )} -
- -
+ )} + {passwordAuthAllowed && } + {passwordAuthAllowed && !instanceGeneralSetting.disallowUserRegistration && ( + + )} + + ) : ( + } + title={t("auth.signin-unavailable-title")} + description={t("auth.signin-unavailable-description")} + > + + {t("auth.admin-sign-in")} + + + + )} + ); }; diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index 014694fa..e767406e 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -1,22 +1,24 @@ import { create } from "@bufbuild/protobuf"; import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { LoaderIcon } from "lucide-react"; +import { InfoIcon, LoaderIcon, LockIcon, SparklesIcon, UserRoundXIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "react-hot-toast"; -import { Link, useSearchParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { setAccessToken } from "@/auth-state"; -import AuthFooter from "@/components/AuthFooter"; +import AuthPageLayout, { AuthChip, AuthEmptyState, AuthLinkPrompt } from "@/components/AuthPageLayout"; +import CredentialFields from "@/components/CredentialFields"; +import IdentityProviderButtons from "@/components/IdentityProviderButtons"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { authServiceClient, userServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import { useInstance } from "@/contexts/InstanceContext"; +import { useIdentityProviderList } from "@/hooks/useIdentityProviderQueries"; import useLoading from "@/hooks/useLoading"; import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; -import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, appendSearchParams, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const SignUp = () => { @@ -29,25 +31,18 @@ const SignUp = () => { const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH; - const canUsePasswordSignUp = !instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth; + const signInPath = appendSearchParams(ROUTES.AUTH, searchParams); - const handleUsernameInputChanged = (e: React.ChangeEvent) => { - const text = e.target.value as string; - setUsername(text); - }; + const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth; + const registrationOpen = !instanceGeneralSetting.disallowUserRegistration; + const needsSetup = profile.needsSetup; + // Provider buttons only render on the SSO-provisioned branch below; skip the request elsewhere. + const identityProviderList = useIdentityProviderList(!needsSetup && registrationOpen && !passwordAuthAllowed); + const hasIdentityProviders = identityProviderList.length > 0; - const handlePasswordInputChanged = (e: React.ChangeEvent) => { - const text = e.target.value as string; - setPassword(text); - }; - - const handleFormSubmit = (e: React.FormEvent) => { + const handleFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); - handleSignUpButtonClick(); - }; - const handleSignUpButtonClick = async () => { if (username === "" || password === "") { return; } @@ -87,75 +82,87 @@ const SignUp = () => { actionBtnLoadingState.setFinish(); }; - return ( -
-
-
- -

{instanceGeneralSetting.customProfile?.title || "Memos"}

+ const signUpForm = ( +
+ + + + ); + + const signInPrompt = ; + + // First run: create the instance owner account. + if (needsSetup) { + return ( + + + {t("auth.first-run")} + + } + title={t("auth.setup-title")} + subtitle={t("auth.setup-description")} + hideExplore + > + {signUpForm} +
+ + {t("auth.setup-note")}
- {canUsePasswordSignUp ? ( - <> -

{t("auth.create-your-account")}

-
-
-
- {t("common.username")} - -
-
- {t("common.password")} - -
-
-
- -
-
- - ) : instanceGeneralSetting.disallowPasswordAuth ? ( -

Password sign up is not allowed.

+
+ ); + } + + // Registration closed. + if (!registrationOpen) { + return ( + + } + title={t("auth.signups-closed-title")} + description={t("auth.signups-closed-description")} + /> + {signInPrompt} + + ); + } + + // Password sign-up disallowed: accounts come from the identity provider. + if (!passwordAuthAllowed) { + return ( + + {hasIdentityProviders ? ( + ) : ( -

Sign up is not allowed.

+ } + title={t("auth.signup-unavailable-title")} + description={t("auth.signup-unavailable-description")} + /> )} - {profile.needsSetup ? ( -

{t("auth.host-tip")}

- ) : ( -

- {t("auth.sign-in-tip")} - - {t("common.sign-in")} - -

- )} -
- -
+ {signInPrompt} + + ); + } + + // Open registration. + return ( + + {signUpForm} + {signInPrompt} + ); }; diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 3f5734f5..916c5118 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -8,6 +8,9 @@ export const ROUTES = { SETTING: "/setting", EXPLORE: "/explore", AUTH: "/auth", + AUTH_SIGNUP: "/auth/signup", + AUTH_ADMIN: "/auth/admin", + AUTH_CALLBACK: "/auth/callback", SHARED_MEMO: "/memos/shares", } as const; diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 9d195342..4c70e37a 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -9,6 +9,7 @@ export { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, + appendSearchParams, buildAuthRoute, getSafeRedirectPath, isPublicRoute, diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts index 3a88c417..234dc5b5 100644 --- a/web/src/utils/redirect-safety.ts +++ b/web/src/utils/redirect-safety.ts @@ -87,3 +87,12 @@ export function shouldGatePrivateInstance(params: { isPrivateInstance: boolean; } return !pathname.startsWith(`${ROUTES.SHARED_MEMO}/`); } + +/** + * Appends the given search params to a path, so links between auth pages + * (sign-in <-> sign-up) preserve the redirect target and other params. + */ +export function appendSearchParams(path: string, searchParams: URLSearchParams): string { + const queryString = searchParams.toString(); + return queryString ? `${path}?${queryString}` : path; +} diff --git a/web/tests/auth-page-layout.test.tsx b/web/tests/auth-page-layout.test.tsx new file mode 100644 index 00000000..3754d6c1 --- /dev/null +++ b/web/tests/auth-page-layout.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AuthPageLayout from "@/components/AuthPageLayout"; + +const instance = vi.hoisted(() => ({ instanceUrl: "" })); + +vi.mock("@/contexts/InstanceContext", () => ({ + useInstance: () => ({ profile: instance, generalSetting: {} }), +})); + +vi.mock("@/utils/i18n", () => ({ + useTranslate: () => (key: string) => key, + loadLocale: vi.fn(), +})); + +vi.mock("@/components/AuthFooter", () => ({ default: () =>
})); + +const renderLayout = (props?: { hideExplore?: boolean }) => + render( + + +
+ + , + ); + +describe(" explore band", () => { + beforeEach(() => { + instance.instanceUrl = ""; + }); + + it("links to Explore on public instances", () => { + instance.instanceUrl = "https://demo.example.com"; + renderLayout(); + + expect(screen.getByRole("link", { name: /auth\.explore-public-memos/ })).toHaveAttribute("href", "/explore"); + }); + + it("omits the band on private instances", () => { + renderLayout(); + + expect(screen.queryByRole("link", { name: /auth\.explore-public-memos/ })).not.toBeInTheDocument(); + }); + + it("omits the band when hideExplore is set (first-run setup)", () => { + instance.instanceUrl = "https://demo.example.com"; + renderLayout({ hideExplore: true }); + + expect(screen.queryByRole("link", { name: /auth\.explore-public-memos/ })).not.toBeInTheDocument(); + }); +});