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
This commit is contained in:
boojack 2026-07-13 19:40:31 +08:00
parent a9fcd459f6
commit 564da949cb
14 changed files with 511 additions and 260 deletions

View file

@ -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 }) => (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-accent/50 px-2.5 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{children}
</span>
);
// 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;
}) => (
<div className="flex flex-col items-center py-2 text-center">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-full bg-accent text-muted-foreground">{icon}</div>
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
{children}
</div>
);
// "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 }) => (
<p className="mt-5 text-center text-sm text-muted-foreground">
{prompt}{" "}
<Link to={to} className="text-primary hover:underline" viewTransition>
{label}
</Link>
</p>
);
const AuthPageLayout = ({ chip, title, subtitle, hideExplore, children }: Props) => {
const t = useTranslate();
const { generalSetting, profile } = useInstance();
const showExplore = Boolean(profile.instanceUrl) && !hideExplore;
return (
<div className="min-h-svh w-full flex flex-col items-center px-4 py-4 sm:py-8">
<div className="w-full grow flex flex-col justify-center items-center">
<div className="w-90 max-w-full rounded-xl border border-border bg-card p-7 shadow-sm">
<div className="mb-6 flex items-center gap-2">
<img className="h-6 w-auto rounded-full" src={generalSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<span className="text-sm font-semibold text-foreground">{generalSetting.customProfile?.title || "Memos"}</span>
</div>
{chip && <div className="mb-2">{chip}</div>}
<h1 className="text-lg font-semibold tracking-tight text-foreground">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
<div className="mt-6 w-full">{children}</div>
{showExplore && (
<div className="-mx-7 -mb-7 mt-6 rounded-b-xl border-t border-border bg-background/60">
<Link
to={ROUTES.EXPLORE}
className="group flex items-center justify-center gap-2 px-7 py-3 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
viewTransition
>
<CompassIcon className="h-3.5 w-3.5" />
{t("auth.explore-public-memos")}
<ArrowRightIcon className="-ml-1 h-3.5 w-3.5 opacity-0 transition-all group-hover:ml-0 group-hover:opacity-100" />
</Link>
</div>
)}
</div>
</div>
<AuthFooter />
</div>
);
};
export default AuthPageLayout;

View file

@ -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 (
<>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-username`}>{t("common.username")}</Label>
<Input
id={`${idPrefix}-username`}
type="text"
readOnly={readOnly}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={(e) => onUsernameChange(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-password`}>{t("common.password")}</Label>
<Input
id={`${idPrefix}-password`}
type="password"
readOnly={readOnly}
placeholder={t("common.password")}
value={password}
autoComplete={passwordAutoComplete}
autoCapitalize="off"
spellCheck={false}
onChange={(e) => onPasswordChange(e.target.value)}
required
/>
</div>
</>
);
};
export default CredentialFields;

View file

@ -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 (
<div className="flex w-full flex-col gap-2">
{identityProviderList.map((identityProvider) => (
<Button key={identityProvider.name} variant="outline" onClick={() => handleSignInWithIdentityProvider(identityProvider)}>
{t("auth.continue-with", { provider: identityProvider.title })}
</Button>
))}
</div>
);
};
export default IdentityProviderButtons;

View file

@ -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<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignInButtonClick();
};
const handleSignInButtonClick = async () => {
if (username === "" || password === "") {
return;
}
@ -72,45 +59,20 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
};
return (
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="current-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignInButtonClick}>
{t("common.sign-in")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
<form className="flex w-full flex-col gap-4" onSubmit={handleFormSubmit}>
<CredentialFields
idPrefix="signin"
username={username}
password={password}
passwordAutoComplete="current-password"
readOnly={actionBtnLoadingState.isLoading}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
/>
<Button type="submit" disabled={actionBtnLoadingState.isLoading}>
{t("common.sign-in")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="ml-1 h-4 w-auto animate-spin opacity-60" />}
</Button>
</form>
);
}

View file

@ -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;
}

View file

@ -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",

View file

@ -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": "统计",

View file

@ -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 (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
<p className="w-full text-xl font-medium text-muted-foreground">Sign in with admin accounts</p>
<PasswordSignInForm />
</div>
<AuthFooter />
</div>
<AuthPageLayout
chip={
<AuthChip>
<ShieldCheckIcon className="h-3 w-3" />
{t("common.admin")}
</AuthChip>
}
title={t("auth.admin-sign-in")}
subtitle={t("auth.admin-sign-in-tip")}
>
<PasswordSignInForm />
<p className="mt-5 text-center text-sm">
<Link to={ROUTES.AUTH} className="inline-flex items-center gap-1 text-primary hover:underline" viewTransition>
<ArrowLeftIcon className="h-3.5 w-3.5" />
{t("auth.back-to-sign-in")}
</Link>
</p>
</AuthPageLayout>
);
};

View file

@ -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<IdentityProvider[]>([]);
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 (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{!instanceGeneralSetting.disallowPasswordAuth ? (
<PasswordSignInForm redirectPath={redirectTarget} />
) : (
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
)}
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-up-tip")}</span>
<Link to={signUpPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-up")}
</Link>
</p>
)}
{identityProviderList.length > 0 && (
<>
{!instanceGeneralSetting.disallowPasswordAuth && (
<div className="relative my-4 w-full">
<AuthPageLayout title={t("common.sign-in")} subtitle={subtitle}>
{passwordAuthAllowed || hasIdentityProviders ? (
<>
{hasIdentityProviders && <IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />}
{hasIdentityProviders && passwordAuthAllowed && (
<div className="my-4 flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
<div className="flex-1">
<Separator />
</div>
{t("common.or")}
<div className="flex-1">
<Separator />
<div className="absolute inset-0 flex items-center justify-center">
<span className="bg-background px-2 text-xs text-muted-foreground">{t("common.or")}</span>
</div>
</div>
)}
<div className="w-full flex flex-col space-y-2">
{identityProviderList.map((identityProvider) => (
<Button
className="bg-background w-full"
key={identityProvider.name}
variant="outline"
onClick={() => handleSignInWithIdentityProvider(identityProvider)}
>
{t("common.sign-in-with", { provider: identityProvider.title })}
</Button>
))}
</div>
</>
)}
</div>
<AuthFooter />
</div>
)}
{passwordAuthAllowed && <PasswordSignInForm redirectPath={redirectTarget} />}
{passwordAuthAllowed && !instanceGeneralSetting.disallowUserRegistration && (
<AuthLinkPrompt prompt={t("auth.sign-up-tip")} to={signUpPath} label={t("common.sign-up")} />
)}
</>
) : (
<AuthEmptyState
icon={<LockIcon className="h-5 w-5" />}
title={t("auth.signin-unavailable-title")}
description={t("auth.signin-unavailable-description")}
>
<Link to={ROUTES.AUTH_ADMIN} className="mt-3 inline-flex items-center gap-1 text-sm text-primary hover:underline" viewTransition>
{t("auth.admin-sign-in")}
<ArrowRightIcon className="h-3.5 w-3.5" />
</Link>
</AuthEmptyState>
)}
</AuthPageLayout>
);
};

View file

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignUpButtonClick();
};
const handleSignUpButtonClick = async () => {
if (username === "" || password === "") {
return;
}
@ -87,75 +82,87 @@ const SignUp = () => {
actionBtnLoadingState.setFinish();
};
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
const signUpForm = (
<form className="flex w-full flex-col gap-4" onSubmit={handleFormSubmit}>
<CredentialFields
idPrefix="signup"
username={username}
password={password}
passwordAutoComplete="new-password"
readOnly={actionBtnLoadingState.isLoading}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
/>
<Button type="submit" disabled={actionBtnLoadingState.isLoading}>
{needsSetup ? t("auth.create-admin-account") : t("common.sign-up")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="ml-1 h-4 w-auto animate-spin opacity-60" />}
</Button>
</form>
);
const signInPrompt = <AuthLinkPrompt prompt={t("auth.sign-in-tip")} to={signInPath} label={t("common.sign-in")} />;
// First run: create the instance owner account.
if (needsSetup) {
return (
<AuthPageLayout
chip={
<AuthChip>
<SparklesIcon className="h-3 w-3" />
{t("auth.first-run")}
</AuthChip>
}
title={t("auth.setup-title")}
subtitle={t("auth.setup-description")}
hideExplore
>
{signUpForm}
<div className="mt-4 flex items-start gap-2 rounded-lg border border-border bg-accent/50 px-3 py-2 text-[13px] leading-relaxed text-muted-foreground">
<InfoIcon className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{t("auth.setup-note")}
</div>
{canUsePasswordSignUp ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="new-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
</>
) : instanceGeneralSetting.disallowPasswordAuth ? (
<p className="w-full text-2xl mt-2 text-muted-foreground">Password sign up is not allowed.</p>
</AuthPageLayout>
);
}
// Registration closed.
if (!registrationOpen) {
return (
<AuthPageLayout title={t("auth.create-your-account")}>
<AuthEmptyState
icon={<UserRoundXIcon className="h-5 w-5" />}
title={t("auth.signups-closed-title")}
description={t("auth.signups-closed-description")}
/>
{signInPrompt}
</AuthPageLayout>
);
}
// Password sign-up disallowed: accounts come from the identity provider.
if (!passwordAuthAllowed) {
return (
<AuthPageLayout title={t("auth.create-your-account")} subtitle={hasIdentityProviders ? t("auth.sso-signup-tip") : undefined}>
{hasIdentityProviders ? (
<IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />
) : (
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
<AuthEmptyState
icon={<LockIcon className="h-5 w-5" />}
title={t("auth.signup-unavailable-title")}
description={t("auth.signup-unavailable-description")}
/>
)}
{profile.needsSetup ? (
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
) : (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
<Link to={signInPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-in")}
</Link>
</p>
)}
</div>
<AuthFooter />
</div>
{signInPrompt}
</AuthPageLayout>
);
}
// Open registration.
return (
<AuthPageLayout title={t("auth.create-your-account")}>
{signUpForm}
{signInPrompt}
</AuthPageLayout>
);
};

View file

@ -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;

View file

@ -9,6 +9,7 @@ export {
AUTH_REASON_PARAM,
AUTH_REASON_PROTECTED_MEMO,
AUTH_REDIRECT_PARAM,
appendSearchParams,
buildAuthRoute,
getSafeRedirectPath,
isPublicRoute,

View file

@ -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;
}

View file

@ -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: () => <div data-testid="auth-footer" /> }));
const renderLayout = (props?: { hideExplore?: boolean }) =>
render(
<MemoryRouter>
<AuthPageLayout title="Sign in" {...props}>
<div />
</AuthPageLayout>
</MemoryRouter>,
);
describe("<AuthPageLayout> 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();
});
});