diff --git a/atsf_server/web/app/(dashboard)/layout.tsx b/atsf_server/web/app/(dashboard)/layout.tsx index 48ada711..8896d9f7 100644 --- a/atsf_server/web/app/(dashboard)/layout.tsx +++ b/atsf_server/web/app/(dashboard)/layout.tsx @@ -1,11 +1,16 @@ import type { ReactNode } from 'react'; import { DashboardShell } from '@/components/layout/dashboard-shell'; +import { DashboardAuthGuard } from '@/features/auth/components/dashboard-auth-guard'; interface DashboardLayoutProps { children: ReactNode; } export default function DashboardLayout({ children }: DashboardLayoutProps) { - return {children}; + return ( + + {children} + + ); } diff --git a/atsf_server/web/app/(public)/login/page.tsx b/atsf_server/web/app/(public)/login/page.tsx index ab6448c1..62260c76 100644 --- a/atsf_server/web/app/(public)/login/page.tsx +++ b/atsf_server/web/app/(public)/login/page.tsx @@ -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() { return ( - + }> + + ); } diff --git a/atsf_server/web/app/(public)/oauth/github/page.tsx b/atsf_server/web/app/(public)/oauth/github/page.tsx index 6496153c..0cdc4425 100644 --- a/atsf_server/web/app/(public)/oauth/github/page.tsx +++ b/atsf_server/web/app/(public)/oauth/github/page.tsx @@ -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() { return ( - + }> + + ); } diff --git a/atsf_server/web/app/(public)/register/page.tsx b/atsf_server/web/app/(public)/register/page.tsx index 9967a65e..af7bab0d 100644 --- a/atsf_server/web/app/(public)/register/page.tsx +++ b/atsf_server/web/app/(public)/register/page.tsx @@ -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() { return ( - + }> + + ); } diff --git a/atsf_server/web/app/(public)/reset/page.tsx b/atsf_server/web/app/(public)/reset/page.tsx index d6801002..b085bb8a 100644 --- a/atsf_server/web/app/(public)/reset/page.tsx +++ b/atsf_server/web/app/(public)/reset/page.tsx @@ -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() { return ( - + }> + + ); } diff --git a/atsf_server/web/app/(public)/user/reset/page.tsx b/atsf_server/web/app/(public)/user/reset/page.tsx new file mode 100644 index 00000000..2c596179 --- /dev/null +++ b/atsf_server/web/app/(public)/user/reset/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/atsf_server/web/components/feedback/inline-message.tsx b/atsf_server/web/components/feedback/inline-message.tsx new file mode 100644 index 00000000..7830c097 --- /dev/null +++ b/atsf_server/web/components/feedback/inline-message.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/lib/utils/cn'; + +type InlineMessageTone = 'info' | 'success' | 'danger'; + +const toneClasses: Record = { + 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 ( +
+ {message} +
+ ); +} diff --git a/atsf_server/web/components/forms/turnstile-widget.tsx b/atsf_server/web/components/forms/turnstile-widget.tsx new file mode 100644 index 00000000..79287c0c --- /dev/null +++ b/atsf_server/web/components/forms/turnstile-widget.tsx @@ -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(null); + const widgetIdRef = useRef(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
; +} diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index 2ea0b9a5..d07d26a9 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -1,5 +1,9 @@ '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 { getCurrentNavigationItem } from '@/lib/utils/navigation'; import { publicEnv } from '@/lib/env/public-env'; @@ -7,10 +11,19 @@ import { useAppShellStore } from '@/store/app-shell'; import { usePathname } from 'next/navigation'; export function DashboardTopbar() { + const router = useRouter(); const pathname = usePathname(); const currentPath = pathname ?? '/'; + const { logout, user } = useAuth(); const toggleSidebar = useAppShellStore((state) => state.toggleSidebar); const currentItem = getCurrentNavigationItem(currentPath); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + const handleLogout = async () => { + setIsLoggingOut(true); + await logout(); + router.replace('/login'); + }; return (
@@ -33,6 +46,11 @@ export function DashboardTopbar() {
+ {user ? ( + + {user.display_name || user.username} + + ) : null} 静态导出模式 @@ -40,6 +58,14 @@ export function DashboardTopbar() { 版本 {publicEnv.appVersion} +
diff --git a/atsf_server/web/components/providers/app-providers.tsx b/atsf_server/web/components/providers/app-providers.tsx index 33c66beb..99fd9a7d 100644 --- a/atsf_server/web/components/providers/app-providers.tsx +++ b/atsf_server/web/components/providers/app-providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { type ReactNode, useState } from 'react'; +import { AuthProvider } from '@/components/providers/auth-provider'; import { ThemeProvider } from '@/components/providers/theme-provider'; interface AppProvidersProps { @@ -27,8 +28,10 @@ export function AppProviders({ children }: AppProvidersProps) { ); return ( - - {children} - + + + {children} + + ); } diff --git a/atsf_server/web/components/providers/auth-provider.tsx b/atsf_server/web/components/providers/auth-provider.tsx new file mode 100644 index 00000000..f0ff50c9 --- /dev/null +++ b/atsf_server/web/components/providers/auth-provider.tsx @@ -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; + setUser: (user: AuthUser | null) => void; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [user, setUserState] = useState(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 {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + + return context; +} diff --git a/atsf_server/web/features/auth/api/auth.ts b/atsf_server/web/features/auth/api/auth.ts new file mode 100644 index 00000000..6bdefd37 --- /dev/null +++ b/atsf_server/web/features/auth/api/auth.ts @@ -0,0 +1,60 @@ +import { apiRequest } from '@/lib/api/client'; +import type { + AuthUser, + LoginPayload, + PasswordResetRequestPayload, + RegisterPayload, +} from '@/types/auth'; + +export function getCurrentUser() { + return apiRequest('/user/self'); +} + +export function login(payload: LoginPayload) { + return apiRequest('/user/login', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function logout() { + return apiRequest('/user/logout'); +} + +export function register(payload: RegisterPayload, turnstileToken?: string) { + const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : ''; + + return apiRequest(`/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(`/verification?${searchParams.toString()}`); +} + +export function sendPasswordResetEmail(email: string, turnstileToken?: string) { + const searchParams = new URLSearchParams({ email }); + if (turnstileToken) { + searchParams.set('turnstile', turnstileToken); + } + + return apiRequest(`/reset_password?${searchParams.toString()}`); +} + +export function resetPassword(payload: PasswordResetRequestPayload) { + return apiRequest('/user/reset', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function exchangeGitHubCode(code: string) { + return apiRequest(`/oauth/github?code=${encodeURIComponent(code)}`); +} diff --git a/atsf_server/web/features/auth/api/public.ts b/atsf_server/web/features/auth/api/public.ts new file mode 100644 index 00000000..9c20a36b --- /dev/null +++ b/atsf_server/web/features/auth/api/public.ts @@ -0,0 +1,6 @@ +import { apiRequest } from '@/lib/api/client'; +import type { PublicStatus } from '@/types/public-status'; + +export function getPublicStatus() { + return apiRequest('/status'); +} diff --git a/atsf_server/web/features/auth/components/auth-form-primitives.tsx b/atsf_server/web/features/auth/components/auth-form-primitives.tsx new file mode 100644 index 00000000..e91aa993 --- /dev/null +++ b/atsf_server/web/features/auth/components/auth-form-primitives.tsx @@ -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 ( + + ); +} + +export function AuthInput(props: InputHTMLAttributes) { + return ( + + ); +} + +export function AuthButton({ + className, + ...props +}: ButtonHTMLAttributes) { + return ( +