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:
parent
a9fcd459f6
commit
564da949cb
14 changed files with 511 additions and 260 deletions
90
web/src/components/AuthPageLayout.tsx
Normal file
90
web/src/components/AuthPageLayout.tsx
Normal 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;
|
||||||
55
web/src/components/CredentialFields.tsx
Normal file
55
web/src/components/CredentialFields.tsx
Normal 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;
|
||||||
67
web/src/components/IdentityProviderButtons.tsx
Normal file
67
web/src/components/IdentityProviderButtons.tsx
Normal 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;
|
||||||
|
|
@ -3,8 +3,8 @@ import { LoaderIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { setAccessToken } from "@/auth-state";
|
import { setAccessToken } from "@/auth-state";
|
||||||
|
import CredentialFields from "@/components/CredentialFields";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { authServiceClient } from "@/connect";
|
import { authServiceClient } from "@/connect";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
|
|
@ -25,22 +25,9 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
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>) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSignInButtonClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignInButtonClick = async () => {
|
|
||||||
if (username === "" || password === "") {
|
if (username === "" || password === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -72,45 +59,20 @@ function PasswordSignInForm({ redirectPath }: PasswordSignInFormProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
|
<form className="flex w-full flex-col gap-4" onSubmit={handleFormSubmit}>
|
||||||
<div className="flex flex-col justify-start items-start w-full gap-4">
|
<CredentialFields
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
idPrefix="signin"
|
||||||
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
|
username={username}
|
||||||
<Input
|
password={password}
|
||||||
className="w-full bg-background h-10"
|
passwordAutoComplete="current-password"
|
||||||
type="text"
|
readOnly={actionBtnLoadingState.isLoading}
|
||||||
readOnly={actionBtnLoadingState.isLoading}
|
onUsernameChange={setUsername}
|
||||||
placeholder={t("common.username")}
|
onPasswordChange={setPassword}
|
||||||
value={username}
|
/>
|
||||||
autoComplete="username"
|
<Button type="submit" disabled={actionBtnLoadingState.isLoading}>
|
||||||
autoCapitalize="off"
|
{t("common.sign-in")}
|
||||||
spellCheck={false}
|
{actionBtnLoadingState.isLoading && <LoaderIcon className="ml-1 h-4 w-auto animate-spin opacity-60" />}
|
||||||
onChange={handleUsernameInputChanged}
|
</Button>
|
||||||
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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
web/src/hooks/useIdentityProviderQueries.ts
Normal file
23
web/src/hooks/useIdentityProviderQueries.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,30 @@
|
||||||
"official-website": "Official Website"
|
"official-website": "Official Website"
|
||||||
},
|
},
|
||||||
"auth": {
|
"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",
|
"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",
|
"new-password": "New password",
|
||||||
"protected-memo-notice": "This memo is not public. Sign in to continue.",
|
"protected-memo-notice": "This memo is not public. Sign in to continue.",
|
||||||
"repeat-new-password": "Repeat the new password",
|
"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-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": {
|
"demo": {
|
||||||
"banner-description": "Changes are temporary and may be reset.",
|
"banner-description": "Changes are temporary and may be reset.",
|
||||||
|
|
@ -137,7 +154,6 @@
|
||||||
"shortcut-filter": "Shortcut filter",
|
"shortcut-filter": "Shortcut filter",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"sign-in": "Sign in",
|
"sign-in": "Sign in",
|
||||||
"sign-in-with": "Sign in with {{provider}}",
|
|
||||||
"sign-out": "Sign out",
|
"sign-out": "Sign out",
|
||||||
"sign-up": "Sign up",
|
"sign-up": "Sign up",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,30 @@
|
||||||
"media": "媒体"
|
"media": "媒体"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
"admin-sign-in": "管理员登录",
|
||||||
|
"admin-sign-in-tip": "管理员始终可以使用密码登录。",
|
||||||
|
"back-to-sign-in": "返回登录",
|
||||||
|
"continue-with": "使用 {{provider}} 继续",
|
||||||
|
"create-admin-account": "创建管理员账号",
|
||||||
"create-your-account": "创建您的账户",
|
"create-your-account": "创建您的账户",
|
||||||
"host-tip": "您正在注册为站点管理员。",
|
"explore-public-memos": "浏览此实例上的公开 Memo",
|
||||||
|
"first-run": "首次运行",
|
||||||
"new-password": "新密码",
|
"new-password": "新密码",
|
||||||
"protected-memo-notice": "此备忘录不是公开的。请先登录后继续。",
|
"protected-memo-notice": "此备忘录不是公开的。请先登录后继续。",
|
||||||
"repeat-new-password": "重复新密码",
|
"repeat-new-password": "重复新密码",
|
||||||
|
"setup-description": "为此实例创建管理员账号。",
|
||||||
|
"setup-note": "该账号拥有实例设置、成员与身份提供商的管理权限。",
|
||||||
|
"setup-title": "初始化实例",
|
||||||
"sign-in-tip": "已有账户?",
|
"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": {
|
"demo": {
|
||||||
"banner-description": "这里的改动是临时的,可能会被重置。",
|
"banner-description": "这里的改动是临时的,可能会被重置。",
|
||||||
|
|
@ -105,7 +122,6 @@
|
||||||
"shortcut-filter": "捷径过滤器",
|
"shortcut-filter": "捷径过滤器",
|
||||||
"shortcuts": "捷径",
|
"shortcuts": "捷径",
|
||||||
"sign-in": "登录",
|
"sign-in": "登录",
|
||||||
"sign-in-with": "使用 {{provider}} 登录",
|
|
||||||
"sign-out": "退出登录",
|
"sign-out": "退出登录",
|
||||||
"sign-up": "注册",
|
"sign-up": "注册",
|
||||||
"statistics": "统计",
|
"statistics": "统计",
|
||||||
|
|
|
||||||
|
|
@ -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 PasswordSignInForm from "@/components/PasswordSignInForm";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const AdminSignIn = () => {
|
const AdminSignIn = () => {
|
||||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
const t = useTranslate();
|
||||||
|
|
||||||
return (
|
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">
|
<AuthPageLayout
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
chip={
|
||||||
<div className="w-full flex flex-row justify-center items-center mb-6">
|
<AuthChip>
|
||||||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
<ShieldCheckIcon className="h-3 w-3" />
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
{t("common.admin")}
|
||||||
</div>
|
</AuthChip>
|
||||||
<p className="w-full text-xl font-medium text-muted-foreground">Sign in with admin accounts</p>
|
}
|
||||||
<PasswordSignInForm />
|
title={t("auth.admin-sign-in")}
|
||||||
</div>
|
subtitle={t("auth.admin-sign-in-tip")}
|
||||||
<AuthFooter />
|
>
|
||||||
</div>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,62 @@
|
||||||
import { useEffect, useState } from "react";
|
import { ArrowRightIcon, LockIcon } from "lucide-react";
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
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 PasswordSignInForm from "@/components/PasswordSignInForm";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { identityProviderServiceClient } from "@/connect";
|
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
import { absolutifyLink } from "@/lib/browser";
|
import { useIdentityProviderList } from "@/hooks/useIdentityProviderQueries";
|
||||||
import { handleError } from "@/lib/error";
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
|
import { AUTH_REDIRECT_PARAM, appendSearchParams, getSafeRedirectPath } from "@/utils/auth-redirect";
|
||||||
import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
|
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { storeOAuthState } from "@/utils/oauth";
|
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
|
||||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const identityProviderList = useIdentityProviderList();
|
||||||
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
|
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.
|
const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth;
|
||||||
useEffect(() => {
|
const hasIdentityProviders = identityProviderList.length > 0;
|
||||||
const fetchIdentityProviderList = async () => {
|
|
||||||
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
|
|
||||||
setIdentityProviderList(identityProviders);
|
|
||||||
};
|
|
||||||
fetchIdentityProviderList();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => {
|
const subtitle = passwordAuthAllowed || hasIdentityProviders ? t("auth.welcome-back") : undefined;
|
||||||
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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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">
|
<AuthPageLayout title={t("common.sign-in")} subtitle={subtitle}>
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
{passwordAuthAllowed || hasIdentityProviders ? (
|
||||||
<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="" />
|
{hasIdentityProviders && <IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />}
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
{hasIdentityProviders && passwordAuthAllowed && (
|
||||||
</div>
|
<div className="my-4 flex items-center gap-3 text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{!instanceGeneralSetting.disallowPasswordAuth ? (
|
<div className="flex-1">
|
||||||
<PasswordSignInForm redirectPath={redirectTarget} />
|
<Separator />
|
||||||
) : (
|
</div>
|
||||||
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
|
{t("common.or")}
|
||||||
)}
|
<div className="flex-1">
|
||||||
{!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">
|
|
||||||
<Separator />
|
<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>
|
||||||
)}
|
|
||||||
<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>
|
||||||
</>
|
)}
|
||||||
)}
|
{passwordAuthAllowed && <PasswordSignInForm redirectPath={redirectTarget} />}
|
||||||
</div>
|
{passwordAuthAllowed && !instanceGeneralSetting.disallowUserRegistration && (
|
||||||
<AuthFooter />
|
<AuthLinkPrompt prompt={t("auth.sign-up-tip")} to={signUpPath} label={t("common.sign-up")} />
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
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 { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { authServiceClient, userServiceClient } from "@/connect";
|
import { authServiceClient, userServiceClient } from "@/connect";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useInstance } from "@/contexts/InstanceContext";
|
import { useInstance } from "@/contexts/InstanceContext";
|
||||||
|
import { useIdentityProviderList } from "@/hooks/useIdentityProviderQueries";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import { handleError } from "@/lib/error";
|
import { handleError } from "@/lib/error";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
|
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";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
|
|
||||||
const SignUp = () => {
|
const SignUp = () => {
|
||||||
|
|
@ -29,25 +31,18 @@ const SignUp = () => {
|
||||||
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
|
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
|
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
|
||||||
const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH;
|
const signInPath = appendSearchParams(ROUTES.AUTH, searchParams);
|
||||||
const canUsePasswordSignUp = !instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth;
|
|
||||||
|
|
||||||
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth;
|
||||||
const text = e.target.value as string;
|
const registrationOpen = !instanceGeneralSetting.disallowUserRegistration;
|
||||||
setUsername(text);
|
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 handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
const text = e.target.value as string;
|
|
||||||
setPassword(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSignUpButtonClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignUpButtonClick = async () => {
|
|
||||||
if (username === "" || password === "") {
|
if (username === "" || password === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -87,75 +82,87 @@ const SignUp = () => {
|
||||||
actionBtnLoadingState.setFinish();
|
actionBtnLoadingState.setFinish();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const signUpForm = (
|
||||||
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
|
<form className="flex w-full flex-col gap-4" onSubmit={handleFormSubmit}>
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<CredentialFields
|
||||||
<div className="w-full flex flex-row justify-center items-center mb-6">
|
idPrefix="signup"
|
||||||
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
|
username={username}
|
||||||
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
|
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>
|
</div>
|
||||||
{canUsePasswordSignUp ? (
|
</AuthPageLayout>
|
||||||
<>
|
);
|
||||||
<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">
|
// Registration closed.
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
if (!registrationOpen) {
|
||||||
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
|
return (
|
||||||
<Input
|
<AuthPageLayout title={t("auth.create-your-account")}>
|
||||||
className="w-full bg-background h-10"
|
<AuthEmptyState
|
||||||
type="text"
|
icon={<UserRoundXIcon className="h-5 w-5" />}
|
||||||
readOnly={actionBtnLoadingState.isLoading}
|
title={t("auth.signups-closed-title")}
|
||||||
placeholder={t("common.username")}
|
description={t("auth.signups-closed-description")}
|
||||||
value={username}
|
/>
|
||||||
autoComplete="username"
|
{signInPrompt}
|
||||||
autoCapitalize="off"
|
</AuthPageLayout>
|
||||||
spellCheck={false}
|
);
|
||||||
onChange={handleUsernameInputChanged}
|
}
|
||||||
required
|
|
||||||
/>
|
// Password sign-up disallowed: accounts come from the identity provider.
|
||||||
</div>
|
if (!passwordAuthAllowed) {
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
return (
|
||||||
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
|
<AuthPageLayout title={t("auth.create-your-account")} subtitle={hasIdentityProviders ? t("auth.sso-signup-tip") : undefined}>
|
||||||
<Input
|
{hasIdentityProviders ? (
|
||||||
className="w-full bg-background h-10"
|
<IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<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 ? (
|
{signInPrompt}
|
||||||
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
|
</AuthPageLayout>
|
||||||
) : (
|
);
|
||||||
<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>
|
// Open registration.
|
||||||
{t("common.sign-in")}
|
return (
|
||||||
</Link>
|
<AuthPageLayout title={t("auth.create-your-account")}>
|
||||||
</p>
|
{signUpForm}
|
||||||
)}
|
{signInPrompt}
|
||||||
</div>
|
</AuthPageLayout>
|
||||||
<AuthFooter />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const ROUTES = {
|
||||||
SETTING: "/setting",
|
SETTING: "/setting",
|
||||||
EXPLORE: "/explore",
|
EXPLORE: "/explore",
|
||||||
AUTH: "/auth",
|
AUTH: "/auth",
|
||||||
|
AUTH_SIGNUP: "/auth/signup",
|
||||||
|
AUTH_ADMIN: "/auth/admin",
|
||||||
|
AUTH_CALLBACK: "/auth/callback",
|
||||||
SHARED_MEMO: "/memos/shares",
|
SHARED_MEMO: "/memos/shares",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export {
|
||||||
AUTH_REASON_PARAM,
|
AUTH_REASON_PARAM,
|
||||||
AUTH_REASON_PROTECTED_MEMO,
|
AUTH_REASON_PROTECTED_MEMO,
|
||||||
AUTH_REDIRECT_PARAM,
|
AUTH_REDIRECT_PARAM,
|
||||||
|
appendSearchParams,
|
||||||
buildAuthRoute,
|
buildAuthRoute,
|
||||||
getSafeRedirectPath,
|
getSafeRedirectPath,
|
||||||
isPublicRoute,
|
isPublicRoute,
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,12 @@ export function shouldGatePrivateInstance(params: { isPrivateInstance: boolean;
|
||||||
}
|
}
|
||||||
return !pathname.startsWith(`${ROUTES.SHARED_MEMO}/`);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
52
web/tests/auth-page-layout.test.tsx
Normal file
52
web/tests/auth-page-layout.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue