Compare commits
2 Commits
5d199c4c5c
...
49ca1f6e1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 49ca1f6e1f | |||
| d1ee0bbad2 |
@@ -2,5 +2,11 @@ node_modules
|
||||
.worktrees
|
||||
.claude
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
dist
|
||||
docs
|
||||
*.zip
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
19
.env.example
19
.env.example
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
packages/web/src/hooks/useClickOutside.ts
Normal file
12
packages/web/src/hooks/useClickOutside.ts
Normal 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]);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user