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 { 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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": "统计",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export {
|
|||
AUTH_REASON_PARAM,
|
||||
AUTH_REASON_PROTECTED_MEMO,
|
||||
AUTH_REDIRECT_PARAM,
|
||||
appendSearchParams,
|
||||
buildAuthRoute,
|
||||
getSafeRedirectPath,
|
||||
isPublicRoute,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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