mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 14:46:36 +08:00
[优化] go 引用调整
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export function AuthFormField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? (
|
||||
<span className="block text-xs text-[var(--foreground-secondary)]">
|
||||
{hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
|
||||
interface DashboardAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const redirect =
|
||||
pathname && pathname !== '/'
|
||||
? `?redirect=${encodeURIComponent(pathname)}`
|
||||
: '';
|
||||
router.replace(`/login${redirect}`);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, pathname, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<LoadingState className="w-full max-w-md" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeGitHubCode} from '@/features/auth/api/auth';
|
||||
|
||||
export function GitHubOAuthCallback() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledCodeRef = useRef<string | null>(null);
|
||||
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: exchangeGitHubCode,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || 'GitHub 授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setPrompt('缺少授权 code');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到 GitHub 授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledCodeRef.current === code) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledCodeRef.current = code;
|
||||
mutation.mutate(code);
|
||||
}, [code, mutation]);
|
||||
|
||||
return (
|
||||
<AppCard title="GitHub OAuth 回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {getOAuthAuthorizeUrl, login} from '@/features/auth/api/auth';
|
||||
import {getPublicStatus} from '@/features/auth/api/public';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const TEXT = {
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||
loginFailed:
|
||||
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
oauthUnavailable: '第三方登录当前不可用。',
|
||||
title: '\u7528\u6237\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
password: '\u5bc6\u7801',
|
||||
loginPending: '\u767b\u5f55\u4e2d...',
|
||||
login: '\u767b\u5f55',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
register: '\u6ce8\u518c',
|
||||
};
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, TEXT.usernameRequired),
|
||||
password: z.string().min(1, TEXT.passwordRequired),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const redirect = searchParams?.get('redirect') || '/';
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['public-status'],
|
||||
queryFn: getPublicStatus,
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
router.replace(redirect);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.loginFailed);
|
||||
},
|
||||
});
|
||||
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: getOAuthAuthorizeUrl,
|
||||
onSuccess: (result) => {
|
||||
window.location.href = result.authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.oauthUnavailable);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
loginMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleOAuthLogin = (sourceName: string) => {
|
||||
setErrorMessage('');
|
||||
oauthMutation.mutate(sourceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label={TEXT.password}>
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||
</AuthButton>
|
||||
</div>
|
||||
|
||||
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
第三方账号登录
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(statusQuery.data?.auth_sources ?? []).map((source) => (
|
||||
<SecondaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(source.name)}
|
||||
className="min-w-36"
|
||||
disabled={oauthMutation.isPending}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/reset"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {exchangeOAuthCode} from '@/features/auth/api/auth';
|
||||
|
||||
function parseOAuthSource(pathname: string | null, sourceParam: string) {
|
||||
const normalizedParam = sourceParam.trim();
|
||||
if (normalizedParam) {
|
||||
return normalizedParam;
|
||||
}
|
||||
|
||||
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
|
||||
if (!pathMatch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(pathMatch[1]);
|
||||
}
|
||||
|
||||
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledRef = useRef('');
|
||||
const [prompt, setPrompt] = useState('正在处理授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
const state = searchParams?.get('state')?.trim() ?? '';
|
||||
const oauthError = searchParams?.get('error')?.trim() ?? '';
|
||||
const oauthErrorDescription =
|
||||
searchParams?.get('error_description')?.trim() ?? '';
|
||||
const resolvedSource =
|
||||
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'link_required') {
|
||||
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
|
||||
router.replace('/oauth/link');
|
||||
return;
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthError || oauthErrorDescription) {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: oauthErrorDescription || oauthError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resolvedSource).trim() === '') {
|
||||
setPrompt('缺少认证源参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到认证源参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setPrompt('缺少授权参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到完整授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${resolvedSource}:${code}:${state}`;
|
||||
if (handledRef.current === key) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = key;
|
||||
mutation.mutate();
|
||||
}, [
|
||||
code,
|
||||
mutation,
|
||||
oauthError,
|
||||
oauthErrorDescription,
|
||||
resolvedSource,
|
||||
state,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppCard title="第三方登录回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
{message?.tone === 'danger' ? (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {linkExistingOAuthAccount} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OAuthLinkExistingForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: linkExistingOAuthAccount,
|
||||
onSuccess: (result) => {
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || '绑定失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="绑定已有账号"
|
||||
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="用户名">
|
||||
<AuthInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label="密码">
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '绑定中...' : '绑定并登录'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {resetPassword} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
export function PasswordResetConfirmForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const email = searchParams?.get('email') || '';
|
||||
const token = searchParams?.get('token') || '';
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => resetPassword({ email, token }),
|
||||
onSuccess: async (password) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setMessage({
|
||||
tone: 'success',
|
||||
text: `密码已重置,新密码已复制到剪贴板:${password}`,
|
||||
});
|
||||
} catch {
|
||||
setMessage({ tone: 'success', text: `密码已重置:${password}` });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '密码重置失败,请重新获取链接。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const missingParams = !email || !token;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="密码重置确认"
|
||||
description="确认后,系统会生成新的随机密码。"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput value={email} readOnly />
|
||||
</AuthFormField>
|
||||
|
||||
{missingParams ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message="重置链接缺少必要参数,请重新发起密码重置。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<AuthButton
|
||||
type="button"
|
||||
disabled={missingParams || mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
{mutation.isPending ? '处理中...' : '确认重置密码'}
|
||||
</AuthButton>
|
||||
{message?.tone === 'success' ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const password = message.text.split(':').pop() || '';
|
||||
if (password) {
|
||||
await navigator.clipboard.writeText(password);
|
||||
}
|
||||
}}
|
||||
>
|
||||
再次复制密码
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[var(--foreground-secondary)]">
|
||||
处理完成后可返回
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
登录页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import {useSearchParams} from 'next/navigation';
|
||||
|
||||
import {PasswordResetConfirmForm} from '@/features/auth/components/password-reset-confirm-form';
|
||||
import {PasswordResetRequestForm} from '@/features/auth/components/password-reset-request-form';
|
||||
|
||||
export function PasswordResetFlow() {
|
||||
const searchParams = useSearchParams();
|
||||
const hasConfirmationParams = Boolean(
|
||||
searchParams?.get('email') && searchParams?.get('token'),
|
||||
);
|
||||
|
||||
return hasConfirmationParams ? (
|
||||
<PasswordResetConfirmForm />
|
||||
) : (
|
||||
<PasswordResetRequestForm />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {z} from 'zod';
|
||||
|
||||
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||
import {AppCard} from '@/components/ui/app-card';
|
||||
import {sendPasswordResetEmail} from '@/features/auth/api/auth';
|
||||
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
|
||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z.string().email('请输入有效邮箱地址'),
|
||||
});
|
||||
|
||||
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
|
||||
|
||||
export function PasswordResetRequestForm() {
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const form = useForm<ResetRequestFormValues>({
|
||||
resolver: zodResolver(resetRequestSchema),
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ResetRequestFormValues) =>
|
||||
sendPasswordResetEmail(values.email),
|
||||
onSuccess: () => {
|
||||
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||
form.reset();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '重置邮件发送失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setMessage(null);
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard
|
||||
title="密码重置"
|
||||
description="提交后,系统会向你的注册邮箱发送重置链接。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="邮箱地址">
|
||||
<AuthInput
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
{form.formState.errors.email ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.email.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '提交中...' : '发送重置邮件'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
想起密码了?
|
||||
<Link
|
||||
href="/login"
|
||||
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
</PublicAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import type {ReactNode} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
|
||||
import {LoadingState} from '@/components/feedback/loading-state';
|
||||
import {useAuth} from '@/components/providers/auth-provider';
|
||||
|
||||
interface PublicAuthGuardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated) {
|
||||
const redirect = searchParams?.get('redirect');
|
||||
router.replace(redirect || '/');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router, searchParams]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
|
||||
|
||||
interface PublicRoutePlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PublicRoutePlaceholder({
|
||||
title,
|
||||
description,
|
||||
}: PublicRoutePlaceholderProps) {
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title={title}
|
||||
description={description}
|
||||
milestones={[
|
||||
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
|
||||
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
|
||||
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user