# 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 ```