feat: OAuth 登录后返回来源页 + 登录页清理

- OAuth 流程透传 redirect 参数,登录后回到触发页面而非固定跳 Dashboard
- 服务端校验 redirect 为相对路径,防止 Open Redirect 攻击
- 隐藏 Apple 登录按钮和邮箱注册入口
- Dark Mode 切换改为下拉菜单样式
- 提取 useClickOutside hook 消除重复代码

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 17:56:57 +08:00
parent d1ee0bbad2
commit 49ca1f6e1f
10 changed files with 116 additions and 78 deletions

View File

@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">

View File

@@ -20,25 +20,17 @@ function GitHubIcon() {
);
}
function AppleIcon() {
return (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}
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 (

View File

@@ -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: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
),
dark: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
system: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
</svg>
),
};
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: JSX.Element }> = [
{
key: 'light',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
),
},
{
key: 'dark',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
},
{
key: 'system',
icon: (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
</svg>
),
},
];
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
const current = themes.find(th => th.key === theme)!;
return (
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
{order.map((key) => (
<button
key={key}
onClick={() => setTheme(key)}
title={t(`theme.${key}` as TranslationKey)}
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
theme === key
? 'bg-bg-elevated text-text-primary shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer hover:bg-bg-tertiary"
style={{ color: 'var(--text-secondary)' }}
aria-label="Switch theme"
>
{current.icon}
</button>
{open && (
<div
className="absolute top-full right-0 mt-1.5 min-w-[140px] rounded-xl py-1 z-50 animate-slide-down"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border-default)',
boxShadow: 'var(--shadow-lg)',
}}
>
{icons[key]}
</button>
))}
{themes.map(th => (
<button
key={th.key}
onClick={() => { setTheme(th.key); setOpen(false); }}
className="flex items-center gap-2.5 w-full px-3 py-2 text-[13px] transition-colors cursor-pointer rounded-lg mx-0.5"
style={{
width: 'calc(100% - 4px)',
color: theme === th.key ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: theme === th.key ? 500 : 400,
background: theme === th.key ? 'var(--bg-tertiary)' : 'transparent',
}}
>
<span className="leading-none">{th.icon}</span>
{t(`theme.${th.key}` as TranslationKey)}
</button>
))}
</div>
)}
</div>
);
}