mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
[重构] 前端改造v2
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
import { DashboardShell } from '@/components/layout/dashboard-shell';
|
||||||
|
import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
return <DashboardShell>{children}</DashboardShell>;
|
return (
|
||||||
|
<DashboardAuthGuard>
|
||||||
|
<DashboardShell>{children}</DashboardShell>
|
||||||
|
</DashboardAuthGuard>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { LoginForm } from '@/features/auth/components/login-form';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<PublicRoutePlaceholder
|
<Suspense fallback={<LoadingState />}>
|
||||||
title='登录页占位'
|
<LoginForm />
|
||||||
description='阶段 2 将在此接入真实登录表单、Session 兼容与登录后跳转。'
|
</Suspense>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback';
|
||||||
|
|
||||||
export default function GithubOAuthPage() {
|
export default function GithubOAuthPage() {
|
||||||
return (
|
return (
|
||||||
<PublicRoutePlaceholder
|
<Suspense fallback={<LoadingState />}>
|
||||||
title='GitHub OAuth 回调占位'
|
<GitHubOAuthCallback />
|
||||||
description='阶段 2 将在此接入授权回调处理、状态提示与跳转逻辑。'
|
</Suspense>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { RegisterForm } from '@/features/auth/components/register-form';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return (
|
||||||
<PublicRoutePlaceholder
|
<Suspense fallback={<LoadingState />}>
|
||||||
title='注册页占位'
|
<RegisterForm />
|
||||||
description='阶段 2 将在此接入注册表单、字段校验与提交流程。'
|
</Suspense>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PasswordResetRequestForm } from '@/features/auth/components/password-reset-request-form';
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<PublicRoutePlaceholder
|
<Suspense fallback={<LoadingState />}>
|
||||||
title='重置密码页占位'
|
<PasswordResetRequestForm />
|
||||||
description='阶段 2 将在此接入找回密码与重置流程。'
|
</Suspense>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PasswordResetConfirmForm } from '@/features/auth/components/password-reset-confirm-form';
|
||||||
|
|
||||||
|
export default function LegacyResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState />}>
|
||||||
|
<PasswordResetConfirmForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
type InlineMessageTone = 'info' | 'success' | 'danger';
|
||||||
|
|
||||||
|
const toneClasses: Record<InlineMessageTone, string> = {
|
||||||
|
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||||
|
success:
|
||||||
|
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||||
|
danger:
|
||||||
|
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InlineMessageProps {
|
||||||
|
tone?: InlineMessageTone;
|
||||||
|
message: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineMessage({
|
||||||
|
tone = 'info',
|
||||||
|
message,
|
||||||
|
className,
|
||||||
|
}: InlineMessageProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
||||||
|
toneClasses[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef } from 'react';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
turnstile?: {
|
||||||
|
render: (
|
||||||
|
container: string | HTMLElement,
|
||||||
|
options: {
|
||||||
|
sitekey: string;
|
||||||
|
callback: (token: string) => void;
|
||||||
|
'expired-callback'?: () => void;
|
||||||
|
'error-callback'?: () => void;
|
||||||
|
theme?: 'auto' | 'light' | 'dark';
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
remove: (widgetId: string) => void;
|
||||||
|
reset: (widgetId?: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
|
||||||
|
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
|
||||||
|
interface TurnstileWidgetProps {
|
||||||
|
siteKey: string;
|
||||||
|
onVerify: (token: string) => void;
|
||||||
|
onExpire?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TurnstileWidget({
|
||||||
|
siteKey,
|
||||||
|
onVerify,
|
||||||
|
onExpire,
|
||||||
|
onError,
|
||||||
|
}: TurnstileWidgetProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const widgetIdRef = useRef<string | null>(null);
|
||||||
|
const elementId = useId().replace(/:/g, '-');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const mountWidget = () => {
|
||||||
|
if (cancelled || !containerRef.current || !window.turnstile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widgetIdRef.current) {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
callback: onVerify,
|
||||||
|
'expired-callback': onExpire,
|
||||||
|
'error-callback': onError,
|
||||||
|
theme: 'auto',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||||
|
|
||||||
|
if (window.turnstile) {
|
||||||
|
mountWidget();
|
||||||
|
} else if (existingScript) {
|
||||||
|
existingScript.addEventListener('load', mountWidget);
|
||||||
|
} else {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = TURNSTILE_SCRIPT_ID;
|
||||||
|
script.src = TURNSTILE_SCRIPT_SRC;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.addEventListener('load', mountWidget);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||||
|
if (script) {
|
||||||
|
script.removeEventListener('load', mountWidget);
|
||||||
|
}
|
||||||
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
window.turnstile.remove(widgetIdRef.current);
|
||||||
|
widgetIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [onError, onExpire, onVerify, siteKey]);
|
||||||
|
|
||||||
|
return <div id={elementId} ref={containerRef} className='min-h-16' />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
|
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
|
||||||
import { publicEnv } from '@/lib/env/public-env';
|
import { publicEnv } from '@/lib/env/public-env';
|
||||||
@@ -7,10 +11,19 @@ import { useAppShellStore } from '@/store/app-shell';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
export function DashboardTopbar() {
|
export function DashboardTopbar() {
|
||||||
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentPath = pathname ?? '/';
|
const currentPath = pathname ?? '/';
|
||||||
|
const { logout, user } = useAuth();
|
||||||
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
||||||
const currentItem = getCurrentNavigationItem(currentPath);
|
const currentItem = getCurrentNavigationItem(currentPath);
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
await logout();
|
||||||
|
router.replace('/login');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
|
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
|
||||||
@@ -33,6 +46,11 @@ export function DashboardTopbar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||||
|
{user ? (
|
||||||
|
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 md:inline-flex'>
|
||||||
|
{user.display_name || user.username}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||||
静态导出模式
|
静态导出模式
|
||||||
@@ -40,6 +58,14 @@ export function DashboardTopbar() {
|
|||||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||||
版本 {publicEnv.appVersion}
|
版本 {publicEnv.appVersion}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
className='inline-flex rounded-full border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-1.5 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60'
|
||||||
|
>
|
||||||
|
{isLoggingOut ? '退出中...' : '退出登录'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||||
|
|
||||||
interface AppProvidersProps {
|
interface AppProvidersProps {
|
||||||
@@ -27,8 +28,10 @@ export function AppProviders({ children }: AppProvidersProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
<ThemeProvider>
|
||||||
</ThemeProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth';
|
||||||
|
import type { AuthUser } from '@/types/auth';
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
refreshUser: () => Promise<AuthUser | null>;
|
||||||
|
setUser: (user: AuthUser | null) => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUserState] = useState<AuthUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nextUser = await getCurrentUser();
|
||||||
|
setUserState(nextUser);
|
||||||
|
return nextUser;
|
||||||
|
} catch {
|
||||||
|
setUserState(null);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshUser();
|
||||||
|
}, [refreshUser]);
|
||||||
|
|
||||||
|
const setUser = useCallback((nextUser: AuthUser | null) => {
|
||||||
|
setUserState(nextUser);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await logoutRequest();
|
||||||
|
} finally {
|
||||||
|
setUserState(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAuthenticated: user !== null,
|
||||||
|
refreshUser,
|
||||||
|
setUser,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
|
[isLoading, logout, refreshUser, setUser, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
import type {
|
||||||
|
AuthUser,
|
||||||
|
LoginPayload,
|
||||||
|
PasswordResetRequestPayload,
|
||||||
|
RegisterPayload,
|
||||||
|
} from '@/types/auth';
|
||||||
|
|
||||||
|
export function getCurrentUser() {
|
||||||
|
return apiRequest<AuthUser>('/user/self');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(payload: LoginPayload) {
|
||||||
|
return apiRequest<AuthUser>('/user/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return apiRequest<void>('/user/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(payload: RegisterPayload, turnstileToken?: string) {
|
||||||
|
const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
|
||||||
|
|
||||||
|
return apiRequest<void>(`/user/register${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendEmailVerification(email: string, turnstileToken?: string) {
|
||||||
|
const searchParams = new URLSearchParams({ email });
|
||||||
|
if (turnstileToken) {
|
||||||
|
searchParams.set('turnstile', turnstileToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
|
||||||
|
const searchParams = new URLSearchParams({ email });
|
||||||
|
if (turnstileToken) {
|
||||||
|
searchParams.set('turnstile', turnstileToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||||
|
return apiRequest<string>('/user/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exchangeGitHubCode(code: string) {
|
||||||
|
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
import type { PublicStatus } from '@/types/public-status';
|
||||||
|
|
||||||
|
export function getPublicStatus() {
|
||||||
|
return apiRequest<PublicStatus>('/status');
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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)] outline-none transition 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,35 @@
|
|||||||
|
'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,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, 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 [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
|
||||||
|
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const code = searchParams?.get('code');
|
||||||
|
if (!code) {
|
||||||
|
setPrompt('缺少授权 code');
|
||||||
|
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(code);
|
||||||
|
}, [mutation, searchParams]);
|
||||||
|
|
||||||
|
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,128 @@
|
|||||||
|
'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 { 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 loginSchema = z.object({
|
||||||
|
username: z.string().min(1, '请输入用户名'),
|
||||||
|
password: z.string().min(1, '请输入密码'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 || '登录失败,请稍后重试。');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
setErrorMessage('');
|
||||||
|
loginMutation.mutate(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGitHubLogin = () => {
|
||||||
|
const clientId = statusQuery.data?.github_client_id;
|
||||||
|
if (!clientId) {
|
||||||
|
setErrorMessage('GitHub 登录当前不可用。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
|
||||||
|
authorizeUrl.searchParams.set('client_id', clientId);
|
||||||
|
authorizeUrl.searchParams.set('scope', 'user:email');
|
||||||
|
window.location.href = authorizeUrl.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicAuthGuard>
|
||||||
|
<AppCard title='用户登录' description='使用现有 ATSFlare 账户登录新版管理端。'>
|
||||||
|
<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}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||||
|
<AuthButton type='submit' disabled={loginMutation.isPending}>
|
||||||
|
{loginMutation.isPending ? '登录中...' : '登录'}
|
||||||
|
</AuthButton>
|
||||||
|
{statusQuery.data?.github_oauth ? (
|
||||||
|
<SecondaryButton type='button' onClick={handleGitHubLogin} className='w-full sm:w-auto'>
|
||||||
|
GitHub 登录
|
||||||
|
</SecondaryButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</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'>
|
||||||
|
忘记密码?点击重置
|
||||||
|
</Link>
|
||||||
|
<span>·</span>
|
||||||
|
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
|
没有账户?点击注册
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</PublicAuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
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>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useMutation, useQuery } 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 { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { sendPasswordResetEmail } from '@/features/auth/api/auth';
|
||||||
|
import { getPublicStatus } from '@/features/auth/api/public';
|
||||||
|
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 [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<ResetRequestFormValues>({
|
||||||
|
resolver: zodResolver(resetRequestSchema),
|
||||||
|
defaultValues: { email: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['public-status'],
|
||||||
|
queryFn: getPublicStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (values: ResetRequestFormValues) =>
|
||||||
|
sendPasswordResetEmail(values.email, turnstileToken || undefined),
|
||||||
|
onSuccess: () => {
|
||||||
|
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setMessage({ tone: 'danger', text: error.message || '重置邮件发送失败,请稍后重试。' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
setMessage(null);
|
||||||
|
if (statusQuery.data?.turnstile_check && !turnstileToken) {
|
||||||
|
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{statusQuery.data?.turnstile_check && statusQuery.data.turnstile_site_key ? (
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={statusQuery.data.turnstile_site_key}
|
||||||
|
onVerify={(token) => setTurnstileToken(token)}
|
||||||
|
onExpire={() => setTurnstileToken('')}
|
||||||
|
onError={() => setTurnstileToken('')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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,225 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
|
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import {
|
||||||
|
register as registerUser,
|
||||||
|
sendEmailVerification,
|
||||||
|
} 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 baseSchemaObject = z.object({
|
||||||
|
username: z.string().min(1, '请输入用户名').max(12, '用户名最长 12 位'),
|
||||||
|
password: z.string().min(8, '密码至少 8 位').max(20, '密码最长 20 位'),
|
||||||
|
password2: z.string().min(8, '请再次输入密码'),
|
||||||
|
email: z.string().optional(),
|
||||||
|
verification_code: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseSchema = baseSchemaObject.refine((data) => data.password === data.password2, {
|
||||||
|
message: '两次输入的密码不一致',
|
||||||
|
path: ['password2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterFormValues = z.infer<typeof baseSchema>;
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['public-status'],
|
||||||
|
queryFn: getPublicStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
|
||||||
|
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
|
||||||
|
|
||||||
|
const schema = useMemo(() => {
|
||||||
|
if (!needsEmailVerification) {
|
||||||
|
return baseSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseSchemaObject
|
||||||
|
.extend({
|
||||||
|
email: z.string().email('请输入有效邮箱地址'),
|
||||||
|
verification_code: z.string().min(1, '请输入验证码'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.password2, {
|
||||||
|
message: '两次输入的密码不一致',
|
||||||
|
path: ['password2'],
|
||||||
|
});
|
||||||
|
}, [needsEmailVerification]);
|
||||||
|
|
||||||
|
const form = useForm<RegisterFormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
password2: '',
|
||||||
|
email: '',
|
||||||
|
verification_code: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: (values: RegisterFormValues) =>
|
||||||
|
registerUser(
|
||||||
|
{
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
email: values.email,
|
||||||
|
verification_code: values.verification_code,
|
||||||
|
},
|
||||||
|
turnstileToken || undefined,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
router.replace('/login');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setMessage({ tone: 'danger', text: error.message || '注册失败,请稍后重试。' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verificationMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const email = form.getValues('email');
|
||||||
|
if (!email) {
|
||||||
|
form.setError('email', { message: '请输入邮箱地址' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendEmailVerification(email, turnstileToken || undefined);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setMessage({ tone: 'success', text: '验证码发送成功,请检查邮箱。' });
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
setMessage({ tone: 'danger', text: error.message || '验证码发送失败,请稍后重试。' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
setMessage(null);
|
||||||
|
if (needsTurnstile && !turnstileToken) {
|
||||||
|
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registerMutation.mutate(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicAuthGuard>
|
||||||
|
<AppCard title='新用户注册' description='兼容现有密码注册链路,后续可继续扩展第三方注册。'>
|
||||||
|
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||||
|
<AuthFormField label='用户名' hint='最长 12 位'>
|
||||||
|
<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='密码' hint='最短 8 位,最长 20 位'>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<AuthFormField label='确认密码'>
|
||||||
|
<AuthInput type='password' placeholder='请再次输入密码' {...form.register('password2')} />
|
||||||
|
{form.formState.errors.password2 ? (
|
||||||
|
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||||
|
{form.formState.errors.password2.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</AuthFormField>
|
||||||
|
|
||||||
|
{needsEmailVerification ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<AuthFormField label='邮箱验证码'>
|
||||||
|
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||||
|
<AuthInput
|
||||||
|
placeholder='请输入验证码'
|
||||||
|
className='flex-1'
|
||||||
|
{...form.register('verification_code')}
|
||||||
|
/>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
if (needsTurnstile && !turnstileToken) {
|
||||||
|
setMessage({ tone: 'info', text: '请先完成人机验证。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(null);
|
||||||
|
verificationMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={verificationMutation.isPending}
|
||||||
|
>
|
||||||
|
{verificationMutation.isPending ? '发送中...' : '获取验证码'}
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
{form.formState.errors.verification_code ? (
|
||||||
|
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||||
|
{form.formState.errors.verification_code.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</AuthFormField>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
|
||||||
|
<TurnstileWidget
|
||||||
|
siteKey={statusQuery.data.turnstile_site_key}
|
||||||
|
onVerify={(token) => setTurnstileToken(token)}
|
||||||
|
onExpire={() => setTurnstileToken('')}
|
||||||
|
onError={() => setTurnstileToken('')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
|
||||||
|
|
||||||
|
<AuthButton type='submit' disabled={registerMutation.isPending}>
|
||||||
|
{registerMutation.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,9 +62,9 @@ export function DashboardOverview() {
|
|||||||
|
|
||||||
<AppCard title='下一步建议' description='按前端改造计划,后续优先进入认证与框架层迁移。'>
|
<AppCard title='下一步建议' description='按前端改造计划,后续优先进入认证与框架层迁移。'>
|
||||||
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
<li>1. 接入登录、注册、重置密码与 OAuth 回调页面。</li>
|
<li>1. 继续完善认证页细节与第三方登录体验。</li>
|
||||||
<li>2. 增加统一鉴权守卫、未登录跳转与消息反馈容器。</li>
|
<li>2. 在业务模块中逐步接入 Query 与 API 资源层。</li>
|
||||||
<li>3. 在业务模块中逐步接入 Query 与 API 资源层。</li>
|
<li>3. 为主链路页面补齐真实列表、表单与动作反馈。</li>
|
||||||
</ol>
|
</ol>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,11 +26,21 @@ export async function apiRequest<T>(path: string, init?: RequestInit) {
|
|||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
let payload: ApiEnvelope<T> | null = null;
|
||||||
throw new ApiError(`请求失败(${response.status})`, response.status);
|
|
||||||
|
try {
|
||||||
|
payload = (await response.json()) as ApiEnvelope<T>;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as ApiEnvelope<T>;
|
if (!response.ok) {
|
||||||
|
throw new ApiError(payload?.message || `请求失败(${response.status})`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new ApiError('响应格式无效', response.status);
|
||||||
|
}
|
||||||
|
|
||||||
if (!payload.success) {
|
if (!payload.success) {
|
||||||
throw new ApiError(payload.message || '请求失败', response.status);
|
throw new ApiError(payload.message || '请求失败', response.status);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
role: number;
|
||||||
|
status: number;
|
||||||
|
email?: string;
|
||||||
|
github_id?: string;
|
||||||
|
wechat_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
email?: string;
|
||||||
|
verification_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetRequestPayload {
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface PublicStatus {
|
||||||
|
version: string;
|
||||||
|
start_time: number;
|
||||||
|
email_verification: boolean;
|
||||||
|
github_oauth: boolean;
|
||||||
|
github_client_id: string;
|
||||||
|
system_name: string;
|
||||||
|
home_page_link: string;
|
||||||
|
footer_html: string;
|
||||||
|
wechat_qrcode: string;
|
||||||
|
wechat_login: boolean;
|
||||||
|
server_address: string;
|
||||||
|
turnstile_check: boolean;
|
||||||
|
turnstile_site_key: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user