From dace447a1447e273f7cc7ebd6b14b8652c2281a4 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:00:33 +0800 Subject: [PATCH] docs: add login page and OAuth implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-03-login-page-oauth.md | 1468 +++++++++++++++++ 1 file changed, 1468 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-03-login-page-oauth.md diff --git a/docs/superpowers/plans/2026-04-03-login-page-oauth.md b/docs/superpowers/plans/2026-04-03-login-page-oauth.md new file mode 100644 index 0000000..0bba359 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-login-page-oauth.md @@ -0,0 +1,1468 @@ +# Login Page Redesign + OAuth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign login/register pages with left-right split layout featuring prominent branding, and add Google/GitHub/Apple OAuth login via server-side redirect flow. + +**Architecture:** Standard OAuth 2.0 authorization code flow. Frontend redirects to backend OAuth routes, backend handles Provider redirect + callback + token exchange, then redirects back to frontend with JWT tokens. All three providers use the same flow. No new dependencies — uses Node native `fetch` and `crypto`. + +**Tech Stack:** Express 5, Prisma, React 19, React Router 7, Tailwind CSS 4, Node native fetch/crypto + +**Spec:** `docs/superpowers/specs/2026-04-03-login-page-oauth-design.md` + +**Worktree:** `.worktrees/feature-login` (branch `feature/login-page`) + +--- + +## File Map + +### New Files + +| File | Responsibility | +|------|---------------| +| `packages/server/src/lib/oauth-providers.ts` | Provider configs (URLs, scopes), buildAuthUrl, exchangeCode, fetchUserInfo for each provider | +| `packages/server/src/routes/oauth.ts` | Express router: GET `/:provider` (redirect to provider), GET `/:provider/callback` (handle callback) | +| `packages/web/src/components/AuthBranding.tsx` | Left panel brand component (logo, name, slogan, features) | +| `packages/web/src/components/OAuthButtons.tsx` | Three OAuth buttons (Google, GitHub, Apple) | +| `packages/web/src/pages/LoginCallback.tsx` | OAuth callback landing page — extracts tokens from URL, stores them, redirects to dashboard | + +### Modified Files + +| File | Change | +|------|--------| +| `packages/server/src/index.ts` | Add `import oauthRouter` and `app.use('/api/auth/oauth', oauthRouter)` | +| `packages/web/src/pages/Login.tsx` | Replace centered card layout with left-right split using AuthBranding + OAuthButtons | +| `packages/web/src/pages/Register.tsx` | Same left-right split layout refactor | +| `packages/web/src/App.tsx` | Add `/login/callback` route | +| `packages/web/src/lib/auth.tsx` | Add `loginWithTokens` method for OAuth callback to set tokens + fetch user | +| `packages/web/src/lib/i18n.tsx` | Add auth-related translation keys | +| `.env.example` | Add Apple OAuth env vars + OAUTH_CALLBACK_BASE_URL | + +--- + +### Task 1: OAuth Provider Configuration + +**Files:** +- Create: `packages/server/src/lib/oauth-providers.ts` + +This file defines provider configs (auth URLs, token URLs, scopes) and three functions: `buildAuthUrl`, `exchangeCodeForToken`, and `fetchProviderUser`. Each provider returns a normalized `{ id, email, name, avatarUrl }`. + +- [ ] **Step 1: Create the provider config file** + +```typescript +// packages/server/src/lib/oauth-providers.ts +import crypto from 'node:crypto'; + +type ProviderConfig = { + authUrl: string; + tokenUrl: string; + userInfoUrl: string | null; + scopes: string[]; + // Apple needs special client_secret JWT generation + buildClientSecret?: () => string; +}; + +type ProviderUser = { + id: string; + email: string; + name: string; + avatarUrl: string | null; +}; + +const providers: Record = { + google: { + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo', + scopes: ['email', 'profile'], + }, + github: { + authUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userInfoUrl: 'https://api.github.com/user', + scopes: ['user:email'], + }, + apple: { + authUrl: 'https://appleid.apple.com/auth/authorize', + tokenUrl: 'https://appleid.apple.com/auth/token', + userInfoUrl: null, // Apple returns user info in id_token + scopes: ['name', 'email'], + }, +}; + +function getClientId(provider: string): string { + const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`; + const value = process.env[envKey]; + if (!value) throw new Error(`Missing env: ${envKey}`); + return value; +} + +function getClientSecret(provider: string): string { + if (provider === 'apple') return buildAppleClientSecret(); + const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`; + const value = process.env[envKey]; + if (!value) throw new Error(`Missing env: ${envKey}`); + return value; +} + +function getCallbackUrl(provider: string): string { + const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000'; + return `${base}/api/auth/oauth/${provider}/callback`; +} + +// --- State management (CSRF protection) --- +const stateStore = new Map(); + +// Clean expired states every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, value] of stateStore) { + if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key); + } +}, 5 * 60 * 1000); + +function generateState(provider: string): string { + const state = crypto.randomBytes(32).toString('hex'); + stateStore.set(state, { provider, createdAt: Date.now() }); + return state; +} + +function validateState(state: string, provider: string): boolean { + const entry = stateStore.get(state); + if (!entry) return false; + if (entry.provider !== provider) return false; + if (Date.now() - entry.createdAt > 10 * 60 * 1000) { + stateStore.delete(state); + return false; + } + stateStore.delete(state); + return true; +} + +// --- Apple client_secret JWT --- +function buildAppleClientSecret(): string { + const teamId = process.env.APPLE_TEAM_ID; + const keyId = process.env.APPLE_KEY_ID; + const privateKey = process.env.APPLE_PRIVATE_KEY; + const clientId = process.env.APPLE_CLIENT_ID; + if (!teamId || !keyId || !privateKey || !clientId) { + throw new Error('Missing Apple OAuth env vars (APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY, APPLE_CLIENT_ID)'); + } + + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'ES256', kid: keyId }; + const payload = { iss: teamId, iat: now, exp: now + 15777000, aud: 'https://appleid.apple.com', sub: clientId }; + + const encode = (obj: object) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const signingInput = `${encode(header)}.${encode(payload)}`; + const key = crypto.createPrivateKey(privateKey.replace(/\\n/g, '\n')); + const sig = crypto.sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' }); + + return `${signingInput}.${sig.toString('base64url')}`; +} + +// --- Public API --- + +export function buildAuthUrl(provider: string): string { + const config = providers[provider]; + if (!config) throw new Error(`Unknown provider: ${provider}`); + + const state = generateState(provider); + const params = new URLSearchParams({ + client_id: getClientId(provider), + redirect_uri: getCallbackUrl(provider), + response_type: 'code', + scope: config.scopes.join(' '), + state, + }); + + // Apple-specific: request name and email, use form_post + if (provider === 'apple') { + params.set('response_mode', 'form_post'); + } + + return `${config.authUrl}?${params.toString()}`; +} + +export async function exchangeCodeForToken(provider: string, code: string): Promise { + const config = providers[provider]; + if (!config) throw new Error(`Unknown provider: ${provider}`); + + const body = new URLSearchParams({ + client_id: getClientId(provider), + client_secret: getClientSecret(provider), + code, + redirect_uri: getCallbackUrl(provider), + grant_type: 'authorization_code', + }); + + const res = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(provider === 'github' ? { Accept: 'application/json' } : {}), + }, + body: body.toString(), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Token exchange failed for ${provider}: ${text}`); + } + + const data = await res.json(); + + if (provider === 'apple') { + // Apple returns id_token, not access_token for user info + return data.id_token as string; + } + + return data.access_token as string; +} + +export async function fetchProviderUser(provider: string, token: string): Promise { + if (provider === 'apple') { + return parseAppleIdToken(token); + } + + const config = providers[provider]; + if (!config?.userInfoUrl) throw new Error(`No userInfo URL for ${provider}`); + + const res = await fetch(config.userInfoUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error(`Failed to fetch user info from ${provider}`); + const data = await res.json(); + + if (provider === 'google') { + return { + id: data.id, + email: data.email, + name: data.name || data.email.split('@')[0], + avatarUrl: data.picture || null, + }; + } + + if (provider === 'github') { + let email = data.email; + if (!email) { + // GitHub may not return email in profile; fetch from /user/emails + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (emailRes.ok) { + const emails = await emailRes.json(); + const primary = emails.find((e: { primary: boolean }) => e.primary); + email = primary?.email || emails[0]?.email; + } + } + return { + id: String(data.id), + email: email || '', + name: data.name || data.login, + avatarUrl: data.avatar_url || null, + }; + } + + throw new Error(`Unknown provider: ${provider}`); +} + +function parseAppleIdToken(idToken: string): ProviderUser { + const parts = idToken.split('.'); + if (parts.length !== 3) throw new Error('Invalid Apple id_token'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + return { + id: payload.sub, + email: payload.email || '', + name: payload.email?.split('@')[0] || 'Apple User', + avatarUrl: null, + }; +} + +export { validateState }; +``` + +- [ ] **Step 2: Verify file compiles** + +Run: `cd packages/server && npx tsc --noEmit src/lib/oauth-providers.ts 2>&1 | head -20` +Expected: No errors (or only errors about missing module resolution that will resolve at build time) + +- [ ] **Step 3: Commit** + +```bash +git add packages/server/src/lib/oauth-providers.ts +git commit -m "feat: add OAuth provider configuration and token exchange utilities" +``` + +--- + +### Task 2: OAuth Routes + +**Files:** +- Create: `packages/server/src/routes/oauth.ts` +- Modify: `packages/server/src/index.ts` + +- [ ] **Step 1: Create the OAuth router** + +```typescript +// packages/server/src/routes/oauth.ts +import { Router, type Router as RouterType } from 'express'; +import { prisma } from '@agent-fox/shared'; +import { generateTokenPair } from '../lib/jwt.js'; +import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState } from '../lib/oauth-providers.js'; + +const router: RouterType = Router(); + +const VALID_PROVIDERS = ['google', 'github', 'apple']; +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; + +// GET /auth/oauth/:provider — redirect to provider's authorization page +router.get('/:provider', (req, res) => { + const { provider } = req.params; + if (!VALID_PROVIDERS.includes(provider)) { + res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } }); + return; + } + + try { + const url = buildAuthUrl(provider); + 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' } }); + } +}); + +// GET /auth/oauth/:provider/callback — handle provider callback +router.get('/:provider/callback', async (req, res) => { + const { provider } = req.params; + const { code, state, error: oauthError } = req.query as Record; + + if (oauthError) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`); + return; + } + + if (!code || !state) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`); + return; + } + + if (!validateState(state, provider)) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`); + return; + } + + try { + // Exchange code for token + const token = await exchangeCodeForToken(provider, code); + + // Fetch user info from provider + const providerUser = await fetchProviderUser(provider, token); + if (!providerUser.email) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`); + return; + } + + // Find or create user + let user = await findOrCreateUser(provider, providerUser); + + // Issue JWT + const tokens = generateTokenPair({ userId: user.id, email: user.email }); + + res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`); + } catch (err) { + console.error(`OAuth callback error (${provider}):`, err); + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`); + } +}); + +// Apple sends callback as POST (form_post response mode) +router.post('/:provider/callback', async (req, res) => { + const { provider } = req.params; + const { code, state, error: oauthError } = req.body; + + if (oauthError) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`); + return; + } + + if (!code || !state) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`); + return; + } + + if (!validateState(state, provider)) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`); + return; + } + + try { + const token = await exchangeCodeForToken(provider, code); + const providerUser = await fetchProviderUser(provider, token); + if (!providerUser.email) { + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`); + return; + } + + let 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}`); + } catch (err) { + console.error(`OAuth POST callback error (${provider}):`, err); + res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`); + } +}); + +async function findOrCreateUser( + provider: string, + providerUser: { id: string; email: string; name: string; avatarUrl: string | null }, +) { + // 1. Check existing OAuthAccount + const existingOAuth = await prisma.oAuthAccount.findUnique({ + where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } }, + include: { user: true }, + }); + if (existingOAuth) { + // Update avatar if changed + if (providerUser.avatarUrl && providerUser.avatarUrl !== existingOAuth.user.avatarUrl) { + await prisma.user.update({ + where: { id: existingOAuth.user.id }, + data: { avatarUrl: providerUser.avatarUrl }, + }); + } + return existingOAuth.user; + } + + // 2. Check existing user by email — link OAuth account + const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } }); + if (existingUser) { + await prisma.oAuthAccount.create({ + data: { userId: existingUser.id, provider, providerAccountId: providerUser.id }, + }); + // Update avatar if user doesn't have one + if (providerUser.avatarUrl && !existingUser.avatarUrl) { + await prisma.user.update({ + where: { id: existingUser.id }, + data: { avatarUrl: providerUser.avatarUrl }, + }); + } + return existingUser; + } + + // 3. Create new user + OAuth account + const newUser = await prisma.user.create({ + data: { + email: providerUser.email, + name: providerUser.name, + avatarUrl: providerUser.avatarUrl, + passwordHash: null, + oauthAccounts: { + create: { provider, providerAccountId: providerUser.id }, + }, + }, + }); + return newUser; +} + +export default router; +``` + +- [ ] **Step 2: Register the OAuth router in server index** + +In `packages/server/src/index.ts`, add: +- Import: `import oauthRouter from './routes/oauth.js';` +- After `app.use(express.json({ limit: '10mb' }));` add: `app.use(express.urlencoded({ extended: true }));` (needed for Apple's form_post) +- Route: `app.use('/api/auth/oauth', oauthRouter);` (add after the existing auth router line) + +```typescript +// packages/server/src/index.ts — full file after changes: +import express from 'express'; +import cors from 'cors'; +import authRouter from './routes/auth.js'; +import oauthRouter from './routes/oauth.js'; +import projectRouter from './routes/projects.js'; +import importRouter from './routes/import.js'; +import moduleRouter from './routes/modules.js'; +import endpointRouter from './routes/endpoints.js'; + +const app = express(); +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +app.get('/api/health', (_req, res) => { + res.json({ success: true, data: { status: 'ok' } }); +}); + +app.use('/api/auth', authRouter); +app.use('/api/auth/oauth', oauthRouter); +app.use('/api/projects', projectRouter); +app.use('/api/projects', importRouter); +app.use('/api/projects', moduleRouter); +app.use('/api/projects', endpointRouter); + +const port = process.env.SERVER_PORT || 3000; +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); +``` + +- [ ] **Step 3: Update .env.example** + +Add these lines at the end of `.env.example`: + +```env +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +OAUTH_CALLBACK_BASE_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:5173 +``` + +- [ ] **Step 4: Verify server compiles** + +Run: `cd packages/server && npx tsc --noEmit 2>&1 | head -20` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/server/src/routes/oauth.ts packages/server/src/index.ts .env.example +git commit -m "feat: add OAuth routes for Google, GitHub, and Apple login" +``` + +--- + +### Task 3: Auth Context — Add loginWithTokens + +**Files:** +- Modify: `packages/web/src/lib/auth.tsx` + +The OAuth callback page needs a way to set tokens received from URL params and fetch the user. Add a `loginWithTokens` method to AuthContext. + +- [ ] **Step 1: Add loginWithTokens to AuthContextType and implementation** + +In `packages/web/src/lib/auth.tsx`: + +Add to the `AuthContextType`: +```typescript +loginWithTokens: (accessToken: string, refreshToken: string) => Promise; +``` + +Add the implementation inside `AuthProvider`, after the `register` function: +```typescript +const loginWithTokens = async (access: string, refresh: string) => { + setTokens(access, refresh); + const user = await apiFetch('/auth/me'); + setUser(user); +}; +``` + +Update the Provider value to include `loginWithTokens`: +```typescript + +``` + +The full file becomes: + +```typescript +// packages/web/src/lib/auth.tsx +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; +import { getAccessToken, clearTokens, setTokens, apiFetch } from './api'; + +type User = { id: string; email: string; name: string }; + +type AuthContextType = { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name: string) => Promise; + loginWithTokens: (accessToken: string, refreshToken: string) => Promise; + logout: () => void; + updateUser: (updates: Partial) => void; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (getAccessToken()) { + apiFetch('/auth/me') + .then(setUser) + .catch(() => clearTokens()) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const login = async (email: string, password: string) => { + const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>( + '/auth/login', + { method: 'POST', body: JSON.stringify({ email, password }) }, + ); + setTokens(data.accessToken, data.refreshToken); + setUser(data.user); + }; + + const register = async (email: string, password: string, name: string) => { + const data = await apiFetch<{ user: User; accessToken: string; refreshToken: string }>( + '/auth/register', + { method: 'POST', body: JSON.stringify({ email, password, name }) }, + ); + setTokens(data.accessToken, data.refreshToken); + setUser(data.user); + }; + + const loginWithTokens = async (access: string, refresh: string) => { + setTokens(access, refresh); + const user = await apiFetch('/auth/me'); + setUser(user); + }; + + const logout = () => { clearTokens(); setUser(null); }; + + const updateUser = (updates: Partial) => { + setUser(prev => prev ? { ...prev, ...updates } : null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/lib/auth.tsx +git commit -m "feat: add loginWithTokens method to auth context for OAuth flow" +``` + +--- + +### Task 4: i18n — Add Auth Translation Keys + +**Files:** +- Modify: `packages/web/src/lib/i18n.tsx` + +- [ ] **Step 1: Add translation keys for auth pages** + +Add these keys to the `en` translations object (after the existing nav keys): + +```typescript +// Auth - Branding +'auth.productName': 'AgentFox', +'auth.slogan': 'API Docs for LLMs, Done Right', +'auth.feature1': 'Multi-level API retrieval for minimal token usage', +'auth.feature2': 'Import OpenAPI specs in seconds', +'auth.feature3': 'Works with any MCP-compatible LLM', + +// Auth - Login +'auth.login.title': 'Sign in to your account', +'auth.login.email': 'Email', +'auth.login.password': 'Password', +'auth.login.submit': 'Sign In', +'auth.login.submitting': 'Signing in...', +'auth.login.noAccount': "Don't have an account?", +'auth.login.signUp': 'Sign Up', +'auth.login.or': 'or continue with', + +// Auth - Register +'auth.register.title': 'Create your account', +'auth.register.subtitle': 'Get started with AgentFox', +'auth.register.name': 'Name', +'auth.register.email': 'Email', +'auth.register.password': 'Password', +'auth.register.submit': 'Create Account', +'auth.register.submitting': 'Creating account...', +'auth.register.hasAccount': 'Already have an account?', +'auth.register.signIn': 'Sign In', +'auth.register.or': 'or continue with', + +// Auth - OAuth +'auth.oauth.google': 'Google', +'auth.oauth.github': 'GitHub', +'auth.oauth.apple': 'Apple', + +// Auth - Callback +'auth.callback.loading': 'Completing sign in...', +'auth.callback.error': 'Sign in failed', +'auth.callback.retry': 'Try again', +``` + +Add corresponding `zh` keys: + +```typescript +// Auth - Branding +'auth.productName': 'AgentFox', +'auth.slogan': 'LLM 专属 API 文档方案', +'auth.feature1': '多级 API 检索,最小化 Token 消耗', +'auth.feature2': '秒级导入 OpenAPI 文档', +'auth.feature3': '兼容所有 MCP 协议的 LLM 工具', + +// Auth - Login +'auth.login.title': '登录到您的账户', +'auth.login.email': '邮箱', +'auth.login.password': '密码', +'auth.login.submit': '登录', +'auth.login.submitting': '登录中...', +'auth.login.noAccount': '还没有账户?', +'auth.login.signUp': '注册', +'auth.login.or': '或者通过以下方式继续', + +// Auth - Register +'auth.register.title': '创建您的账户', +'auth.register.subtitle': '开始使用 AgentFox', +'auth.register.name': '姓名', +'auth.register.email': '邮箱', +'auth.register.password': '密码', +'auth.register.submit': '创建账户', +'auth.register.submitting': '创建中...', +'auth.register.hasAccount': '已经有账户了?', +'auth.register.signIn': '登录', +'auth.register.or': '或者通过以下方式继续', + +// Auth - OAuth +'auth.oauth.google': 'Google', +'auth.oauth.github': 'GitHub', +'auth.oauth.apple': 'Apple', + +// Auth - Callback +'auth.callback.loading': '正在完成登录...', +'auth.callback.error': '登录失败', +'auth.callback.retry': '重试', +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/lib/i18n.tsx +git commit -m "feat: add i18n translation keys for auth pages" +``` + +--- + +### Task 5: AuthBranding Component + +**Files:** +- Create: `packages/web/src/components/AuthBranding.tsx` + +The shared left-panel branding component used by both Login and Register pages. + +- [ ] **Step 1: Create the component** + +```tsx +// packages/web/src/components/AuthBranding.tsx +import { useI18n } from '../lib/i18n'; + +export default function AuthBranding() { + const { t } = useI18n(); + + return ( +
+ {/* Decorative circles */} +
+
+ +
+ {/* Logo */} +
+ + + + + +
+ + {/* Product name */} +

+ {t('auth.productName')} +

+ + {/* Slogan */} +

+ {t('auth.slogan')} +

+ + {/* Feature highlights */} +
+ {['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => ( +
+
+ + + +
+ {t(key)} +
+ ))} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/components/AuthBranding.tsx +git commit -m "feat: add AuthBranding component for login/register left panel" +``` + +--- + +### Task 6: OAuthButtons Component + +**Files:** +- Create: `packages/web/src/components/OAuthButtons.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +// packages/web/src/components/OAuthButtons.tsx +import { useI18n } from '../lib/i18n'; + +const API_BASE = '/api'; + +function GoogleIcon() { + return ( + + + + + + + ); +} + +function GitHubIcon() { + return ( + + + + ); +} + +function AppleIcon() { + return ( + + + + ); +} + +export default function OAuthButtons() { + const { t } = useI18n(); + + const handleOAuth = (provider: string) => { + window.location.href = `${API_BASE}/auth/oauth/${provider}`; + }; + + 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 ( +
+ {buttons.map(({ provider, icon: Icon, label }) => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/web/src/components/OAuthButtons.tsx +git commit -m "feat: add OAuthButtons component with Google, GitHub, Apple icons" +``` + +--- + +### Task 7: LoginCallback Page + +**Files:** +- Create: `packages/web/src/pages/LoginCallback.tsx` +- Modify: `packages/web/src/App.tsx` + +- [ ] **Step 1: Create the callback page** + +```tsx +// packages/web/src/pages/LoginCallback.tsx +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { useI18n } from '../lib/i18n'; + +export default function LoginCallback() { + const [searchParams] = useSearchParams(); + const [error, setError] = useState(''); + const { loginWithTokens } = useAuth(); + const navigate = useNavigate(); + const { t } = useI18n(); + + useEffect(() => { + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const errorParam = searchParams.get('error'); + + if (errorParam) { + setError(errorParam); + return; + } + + if (!accessToken || !refreshToken) { + setError('Missing authentication tokens'); + return; + } + + // Clear tokens from URL immediately + window.history.replaceState({}, '', '/login/callback'); + + loginWithTokens(accessToken, refreshToken) + .then(() => navigate('/dashboard', { replace: true })) + .catch((err) => setError(err instanceof Error ? err.message : 'Authentication failed')); + }, []); + + if (error) { + return ( +
+
+
+ + + + +
+

{t('auth.callback.error')}

+

{error}

+ + {t('auth.callback.retry')} + +
+
+ ); + } + + return ( +
+
+ + + + +

{t('auth.callback.loading')}

+
+
+ ); +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +In `packages/web/src/App.tsx`: + +Add import at the top: +```typescript +import LoginCallback from './pages/LoginCallback'; +``` + +Add route after the `/register` route: +```tsx +} /> +``` + +The full file becomes: + +```tsx +// packages/web/src/App.tsx +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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'; +import ProjectDetail from './pages/ProjectDetail'; +import LandingPage from './pages/landing/LandingPage'; +const queryClient = new QueryClient(); + +export default function App() { + return ( + + + + + + + } /> + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + + + + ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/pages/LoginCallback.tsx packages/web/src/App.tsx +git commit -m "feat: add LoginCallback page and route for OAuth redirect handling" +``` + +--- + +### Task 8: Refactor Login Page — Left-Right Split Layout + +**Files:** +- Modify: `packages/web/src/pages/Login.tsx` + +Replace the entire file with the new split layout, using AuthBranding for the left panel and OAuthButtons below the form. + +- [ ] **Step 1: Rewrite Login.tsx** + +```tsx +// packages/web/src/pages/Login.tsx +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { useI18n } from '../lib/i18n'; +import AuthBranding from '../components/AuthBranding'; +import OAuthButtons from '../components/OAuthButtons'; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({}); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get('redirect') || '/dashboard'; + const { t } = useI18n(); + + const validate = () => { + const errors: { email?: string; password?: string } = {}; + if (!email.trim()) { + errors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.email = 'Please enter a valid email address'; + } + if (!password) { + errors.password = 'Password is required'; + } + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!validate()) return; + setLoading(true); + try { + await login(email, password); + navigate(redirectTo); + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Left panel — branding (hidden on mobile) */} + + + {/* Right panel — form */} +
+ {/* Subtle grid background */} +
+
+ +
+ {/* Mobile-only brand (visible when left panel is hidden) */} +
+
+ + + + + +
+

{t('auth.productName')}

+

{t('auth.slogan')}

+
+ + {/* Title (desktop) */} +
+

{t('auth.login.title')}

+
+ + {/* Card */} +
+ {error && ( +
+ + {error} +
+ )} +
+
+ + { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }} + className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`} + placeholder="you@example.com" + /> + {fieldErrors.email && ( +

+ + {fieldErrors.email} +

+ )} +
+
+ + { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }} + className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`} + placeholder="Enter your password" + /> + {fieldErrors.password && ( +

+ + {fieldErrors.password} +

+ )} +
+ +
+ + {/* Divider */} +
+
+ {t('auth.login.or')} +
+
+ + {/* OAuth buttons */} + +
+ +

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

+
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify page renders** + +Run: `cd packages/web && npx tsc --noEmit 2>&1 | head -20` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/pages/Login.tsx +git commit -m "feat: redesign login page with left-right split layout and OAuth buttons" +``` + +--- + +### Task 9: Refactor Register Page — Left-Right Split Layout + +**Files:** +- Modify: `packages/web/src/pages/Register.tsx` + +Same left-right split pattern as Login. + +- [ ] **Step 1: Rewrite Register.tsx** + +```tsx +// packages/web/src/pages/Register.tsx +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; +import { useI18n } from '../lib/i18n'; +import AuthBranding from '../components/AuthBranding'; +import OAuthButtons from '../components/OAuthButtons'; + +export default function Register() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string; password?: string }>({}); + const [loading, setLoading] = useState(false); + const { register } = useAuth(); + const navigate = useNavigate(); + const { t } = useI18n(); + + const clearFieldError = (field: string) => { + if (fieldErrors[field as keyof typeof fieldErrors]) { + setFieldErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const validate = () => { + const errors: { name?: string; email?: string; password?: string } = {}; + if (!name.trim()) errors.name = 'Name is required'; + if (!email.trim()) { + errors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.email = 'Please enter a valid email address'; + } + if (!password) { + errors.password = 'Password is required'; + } else if (password.length < 8) { + errors.password = 'Password must be at least 8 characters'; + } + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!validate()) return; + setLoading(true); + try { + await register(email, password, name); + navigate('/dashboard'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed'); + } finally { + setLoading(false); + } + }; + + const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!'; + + return ( +
+ {/* Left panel — branding (hidden on mobile) */} + + + {/* Right panel — form */} +
+
+
+ +
+ {/* Mobile-only brand */} +
+
+ + + + + +
+

{t('auth.productName')}

+

{t('auth.slogan')}

+
+ + {/* Title (desktop) */} +
+

{t('auth.register.title')}

+

{t('auth.register.subtitle')}

+
+ +
+ {error && ( +
+ + {error} +
+ )} +
+
+ + { setName(e.target.value); clearFieldError('name'); }} + className={`input-base ${fieldErrors.name ? errorInputClass : ''}`} + placeholder="Your name" + /> + {fieldErrors.name && ( +

+ + {fieldErrors.name} +

+ )} +
+
+ + { setEmail(e.target.value); clearFieldError('email'); }} + className={`input-base ${fieldErrors.email ? errorInputClass : ''}`} + placeholder="you@example.com" + /> + {fieldErrors.email && ( +

+ + {fieldErrors.email} +

+ )} +
+
+ + { setPassword(e.target.value); clearFieldError('password'); }} + className={`input-base ${fieldErrors.password ? errorInputClass : ''}`} + placeholder="At least 8 characters" + /> + {fieldErrors.password && ( +

+ + {fieldErrors.password} +

+ )} +
+ +
+ + {/* Divider */} +
+
+ {t('auth.register.or')} +
+
+ + {/* OAuth buttons */} + +
+ +

+ {t('auth.register.hasAccount')}{' '} + {t('auth.register.signIn')} +

+
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify page compiles** + +Run: `cd packages/web && npx tsc --noEmit 2>&1 | head -20` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add packages/web/src/pages/Register.tsx +git commit -m "feat: redesign register page with left-right split layout and OAuth buttons" +``` + +--- + +### Task 10: Final Verification + +- [ ] **Step 1: Build shared package** + +Run: `cd packages/shared && pnpm build` +Expected: Clean build + +- [ ] **Step 2: Build server package** + +Run: `cd packages/server && pnpm build` +Expected: Clean build, no errors + +- [ ] **Step 3: Build web package** + +Run: `cd packages/web && pnpm build` +Expected: Clean build, no errors + +- [ ] **Step 4: Visual smoke test** + +Start the dev server: `pnpm dev:web` +Check these pages in the browser: +- `/login` — left-right split, branding on left, form + OAuth buttons on right +- `/register` — same layout with registration fields +- Resize to mobile — left panel hidden, compact brand header shown +- Click any OAuth button — should redirect to `/api/auth/oauth/{provider}` (will fail without credentials, but URL should be correct) + +- [ ] **Step 5: Commit any final fixes and push** + +```bash +git push -u origin feature/login-page +```