Compare commits

..

2 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:56:57 +08:00
d1ee0bbad2 chore: 加固生产部署配置
- 移除未使用的 Redis 服务
- 移除 PostgreSQL 端口暴露,仅保留 Docker 内部访问
- server 添加 healthcheck,mcp 依赖 server 确保 migration 完成后启动
- .dockerignore 排除 .env 等敏感文件
- .env.example 对齐实际所需字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 17:43:09 +08:00
14 changed files with 134 additions and 120 deletions

View File

@@ -2,5 +2,11 @@ node_modules
.worktrees
.claude
.git
.env
.env.*
!.env.example
dist
docs
*.zip
*.md
!README.md

View File

@@ -1,18 +1,9 @@
DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox
JWT_SECRET=change-me-to-a-random-secret
JWT_REFRESH_SECRET=change-me-to-another-random-secret
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
API_KEY_ENCRYPTION_SECRET=change-me-to-a-64-char-hex-string
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
MCP_BASE_URL=http://localhost:3001
SERVER_PORT=3000
MCP_PORT=3001
WEB_PORT=5173
REDIS_URL=redis://localhost:6379
APPLE_CLIENT_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY=
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
OAUTH_CALLBACK_BASE_URL=https://your-domain.com
FRONTEND_URL=https://your-domain.com

View File

@@ -3,10 +3,6 @@ services:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
server:
build:
context: .
@@ -28,7 +24,6 @@ services:
- ./prisma:/app/prisma
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
JWT_SECRET: dev-secret
JWT_REFRESH_SECRET: dev-refresh-secret
SERVER_PORT: "3000"
@@ -53,7 +48,6 @@ services:
- ./prisma:/app/prisma
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
MCP_PORT: "3001"
NODE_ENV: development

View File

@@ -7,33 +7,18 @@ services:
POSTGRES_DB: agentfox
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentfox"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: .
dockerfile: packages/server/Dockerfile
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
@@ -49,8 +34,12 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
mcp:
build:
@@ -58,14 +47,11 @@ services:
dockerfile: packages/mcp/Dockerfile
environment:
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
REDIS_URL: redis://redis:6379
MCP_PORT: "3001"
ports:
- "3001:3001"
depends_on:
postgres:
condition: service_healthy
redis:
server:
condition: service_healthy
web:
@@ -80,4 +66,3 @@ services:
volumes:
pgdata:
redisdata:

View File

@@ -57,7 +57,7 @@ function getCallbackUrl(provider: Provider): string {
return `${base}/api/auth/oauth/${provider}/callback`;
}
const stateStore = new Map<string, { provider: string; createdAt: number }>();
const stateStore = new Map<string, { provider: string; createdAt: number; redirect?: string }>();
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),

View File

@@ -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')}`);

View File

@@ -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() {
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/login/callback" element={<LoginCallback />} />
<Route path="/dashboard" element={<Layout />}>
<Route index element={<Projects />} />

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}>
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>
),
dark: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
},
{
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>
),
system: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
},
{
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>
),
};
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
},
];
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) => (
<div ref={ref} className="relative">
<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'
}`}
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"
>
{icons[key]}
{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)',
}}
>
{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>
);
}

View File

@@ -0,0 +1,12 @@
import { useEffect, type RefObject } from 'react';
export function useClickOutside(ref: RefObject<HTMLElement | null>, 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]);
}

View File

@@ -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() {
<div className="flex-1 h-px bg-border-default" />
</div>
<OAuthButtons />
<OAuthButtons redirectTo={redirectTo} />
</div>
<p className="text-center text-[13px] text-text-muted mt-6">
{t('auth.login.noAccount')}{' '}
<Link to="/register" className="text-accent hover:underline font-medium">{t('auth.login.signUp')}</Link>
</p>
{/* Sign Up 入口暂时隐藏,待添加验证码后恢复 */}
</div>
</div>
</div>

View File

@@ -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'));
}, []);

View File

@@ -161,7 +161,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
{/* Logged out: Sign In + Get Started */}
{!loading && !user && (
<div className="hidden sm:flex items-center gap-2">
<Link to="/login" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
<Link to="/login?redirect=/" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
{t('nav.signIn')}
</Link>
<Link
@@ -228,7 +228,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
<div className="border-t border-border-muted my-4!" />
{!loading && !user && (
<div className="space-y-2 px-4">
<Link to="/login" onClick={() => 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">
<Link to="/login?redirect=/" onClick={() => 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')}
</Link>
<Link