diff --git a/packages/server/src/lib/oauth-providers.ts b/packages/server/src/lib/oauth-providers.ts index d409506..037c069 100644 --- a/packages/server/src/lib/oauth-providers.ts +++ b/packages/server/src/lib/oauth-providers.ts @@ -57,7 +57,7 @@ function getCallbackUrl(provider: Provider): string { return `${base}/api/auth/oauth/${provider}/callback`; } -const stateStore = new Map(); +const stateStore = new Map(); const cleanupTimer = setInterval(() => { const now = Date.now(); @@ -67,22 +67,28 @@ const cleanupTimer = setInterval(() => { }, 5 * 60 * 1000); cleanupTimer.unref(); -function generateState(provider: Provider): string { +function isValidRedirect(redirect: string): boolean { + return redirect.startsWith('/') && !redirect.startsWith('//'); +} + +function generateState(provider: Provider, redirect?: string): string { const state = crypto.randomBytes(32).toString('hex'); - stateStore.set(state, { provider, createdAt: Date.now() }); + const safeRedirect = redirect && isValidRedirect(redirect) ? redirect : undefined; + stateStore.set(state, { provider, createdAt: Date.now(), redirect: safeRedirect }); return state; } -function validateState(state: string, provider: Provider): boolean { +function validateState(state: string, provider: Provider): { valid: boolean; redirect?: string } { const entry = stateStore.get(state); - if (!entry) return false; - if (entry.provider !== provider) return false; + if (!entry) return { valid: false }; + if (entry.provider !== provider) return { valid: false }; if (Date.now() - entry.createdAt > 10 * 60 * 1000) { stateStore.delete(state); - return false; + return { valid: false }; } + const redirect = entry.redirect; stateStore.delete(state); - return true; + return { valid: true, redirect }; } function buildAppleClientSecret(): string { @@ -106,11 +112,11 @@ function buildAppleClientSecret(): string { return `${signingInput}.${sig.toString('base64url')}`; } -export function buildAuthUrl(provider: Provider): string { +export function buildAuthUrl(provider: Provider, redirect?: string): string { const config = providers[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); - const state = generateState(provider); + const state = generateState(provider, redirect); const params = new URLSearchParams({ client_id: getClientId(provider), redirect_uri: getCallbackUrl(provider), diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts index 1d7f2c3..767dca6 100644 --- a/packages/server/src/routes/oauth.ts +++ b/packages/server/src/routes/oauth.ts @@ -20,7 +20,8 @@ router.get('/:provider', (req, res) => { } try { - const url = buildAuthUrl(provider); + const redirect = req.query.redirect as string | undefined; + const url = buildAuthUrl(provider, redirect); res.redirect(url); } catch (err) { res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } }); @@ -56,7 +57,8 @@ async function handleOAuthCallback( return; } - if (!validateState(state, provider)) { + const stateResult = validateState(state, provider); + if (!stateResult.valid) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`); return; } @@ -72,7 +74,8 @@ async function handleOAuthCallback( const user = await findOrCreateUser(provider, providerUser); const tokens = generateTokenPair({ userId: user.id, email: user.email }); - res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`); + const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : ''; + res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`); } catch (err) { console.error(`OAuth callback error (${provider}):`, err); res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 625b16b..dfc71ee 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -4,7 +4,6 @@ import { AuthProvider } from './lib/auth'; import { ThemeProvider } from './lib/theme'; import { I18nProvider } from './lib/i18n'; import Login from './pages/Login'; -import Register from './pages/Register'; import LoginCallback from './pages/LoginCallback'; import Layout from './pages/Layout'; import Projects from './pages/Projects'; @@ -22,7 +21,6 @@ export default function App() { } /> } /> - } /> } /> }> } /> diff --git a/packages/web/src/components/LanguageToggle.tsx b/packages/web/src/components/LanguageToggle.tsx index 05ab3b2..9aba614 100644 --- a/packages/web/src/components/LanguageToggle.tsx +++ b/packages/web/src/components/LanguageToggle.tsx @@ -1,5 +1,6 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { useI18n, type Locale } from '../lib/i18n'; +import { useClickOutside } from '../hooks/useClickOutside'; const languages: { locale: Locale; flag: string; label: string }[] = [ { locale: 'en', flag: '🇺🇸', label: 'English' }, @@ -10,15 +11,7 @@ export default function LanguageToggle() { const { locale, setLocale } = useI18n(); const [open, setOpen] = useState(false); const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [open]); + useClickOutside(ref, useCallback(() => setOpen(false), []), open); return (
diff --git a/packages/web/src/components/OAuthButtons.tsx b/packages/web/src/components/OAuthButtons.tsx index 9a2014a..72e32aa 100644 --- a/packages/web/src/components/OAuthButtons.tsx +++ b/packages/web/src/components/OAuthButtons.tsx @@ -20,25 +20,17 @@ function GitHubIcon() { ); } -function AppleIcon() { - return ( - - - - ); -} - -export default function OAuthButtons() { +export default function OAuthButtons({ redirectTo }: { redirectTo?: string }) { const { t } = useI18n(); const handleOAuth = (provider: string) => { - window.location.href = `${API_BASE}/auth/oauth/${provider}`; + const params = redirectTo ? `?redirect=${encodeURIComponent(redirectTo)}` : ''; + window.location.href = `${API_BASE}/auth/oauth/${provider}${params}`; }; const buttons = [ { provider: 'google', icon: GoogleIcon, label: t('auth.oauth.google') }, { provider: 'github', icon: GitHubIcon, label: t('auth.oauth.github') }, - { provider: 'apple', icon: AppleIcon, label: t('auth.oauth.apple') }, ]; return ( diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx index 74ee49a..6c138b7 100644 --- a/packages/web/src/components/ThemeToggle.tsx +++ b/packages/web/src/components/ThemeToggle.tsx @@ -1,46 +1,82 @@ +import { useState, useRef, useCallback } from 'react'; import { useTheme } from '../lib/theme'; import { useI18n, type TranslationKey } from '../lib/i18n'; +import { useClickOutside } from '../hooks/useClickOutside'; -const icons = { - light: ( - - - - ), - dark: ( - - - - ), - system: ( - - - - ), -}; - -const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; +const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: JSX.Element }> = [ + { + key: 'light', + icon: ( + + + + ), + }, + { + key: 'dark', + icon: ( + + + + ), + }, + { + key: 'system', + icon: ( + + + + ), + }, +]; export default function ThemeToggle() { const { theme, setTheme } = useTheme(); const { t } = useI18n(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + useClickOutside(ref, useCallback(() => setOpen(false), []), open); + + const current = themes.find(th => th.key === theme)!; return ( -
- {order.map((key) => ( - + + {open && ( +
- {icons[key]} - - ))} + {themes.map(th => ( + + ))} +
+ )}
); } diff --git a/packages/web/src/hooks/useClickOutside.ts b/packages/web/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..d900203 --- /dev/null +++ b/packages/web/src/hooks/useClickOutside.ts @@ -0,0 +1,12 @@ +import { useEffect, type RefObject } from 'react'; + +export function useClickOutside(ref: RefObject, onClose: () => void, active: boolean) { + useEffect(() => { + if (!active) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [active, ref, onClose]); +} diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx index f6e12ac..c55ec41 100644 --- a/packages/web/src/pages/Login.tsx +++ b/packages/web/src/pages/Login.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../lib/auth'; import { useI18n } from '../lib/i18n'; import AuthBranding, { MobileBranding } from '../components/AuthBranding'; @@ -119,13 +119,10 @@ export default function Login() {
- +
-

- {t('auth.login.noAccount')}{' '} - {t('auth.login.signUp')} -

+ {/* Sign Up 入口暂时隐藏,待添加验证码后恢复 */} diff --git a/packages/web/src/pages/LoginCallback.tsx b/packages/web/src/pages/LoginCallback.tsx index 348879c..5fd0c06 100644 --- a/packages/web/src/pages/LoginCallback.tsx +++ b/packages/web/src/pages/LoginCallback.tsx @@ -14,6 +14,7 @@ export default function LoginCallback() { const accessToken = searchParams.get('accessToken'); const refreshToken = searchParams.get('refreshToken'); const errorParam = searchParams.get('error'); + const redirectTo = searchParams.get('redirect') || '/dashboard'; if (errorParam) { setError(errorParam); @@ -29,7 +30,7 @@ export default function LoginCallback() { window.history.replaceState({}, '', '/login/callback'); loginWithTokens(accessToken, refreshToken) - .then(() => navigate('/dashboard', { replace: true })) + .then(() => navigate(redirectTo, { replace: true })) .catch((err) => setError(err instanceof Error ? err.message : 'Authentication failed')); }, []); diff --git a/packages/web/src/pages/landing/LandingNav.tsx b/packages/web/src/pages/landing/LandingNav.tsx index 71a016e..0d166e0 100644 --- a/packages/web/src/pages/landing/LandingNav.tsx +++ b/packages/web/src/pages/landing/LandingNav.tsx @@ -161,7 +161,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject - + {t('nav.signIn')} {!loading && !user && (
- setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg"> + setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg"> {t('nav.signIn')}