From 3c53bf08bbbab4b434308705e8e120631b36f404 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 12:55:44 +0800 Subject: [PATCH 01/11] docs: add login page redesign and OAuth support design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-03-login-page-oauth-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-03-login-page-oauth-design.md diff --git a/docs/superpowers/specs/2026-04-03-login-page-oauth-design.md b/docs/superpowers/specs/2026-04-03-login-page-oauth-design.md new file mode 100644 index 0000000..c4a5dcc --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-login-page-oauth-design.md @@ -0,0 +1,144 @@ +# Login Page Redesign + OAuth Support + +## Overview + +Redesign the login/register pages with a left-right split layout featuring prominent branding, and add Google/GitHub/Apple OAuth login via standard server-side redirect flow. + +## UI Design + +### Layout + +- **Desktop**: 50/50 left-right split +- **Mobile**: Brand area hidden or collapsed to compact top banner; form area full-width + +### Left Panel (Brand Area) + +Shared `AuthBranding` component used by both Login and Register pages. + +- Dark/gradient background (fox-amber → fox-orange gradient from existing CSS variables) +- Large product icon (~80px SVG fox logo) +- Product name "AgentFox" (large heading font) +- Slogan "API Docs for LLMs, Done Right" +- 3 feature highlights, each with icon + text: + - "Multi-level API retrieval for minimal token usage" + - "Import OpenAPI specs in seconds" + - "Works with any MCP-compatible LLM" + +### Right Panel (Form Area) + +- Light background, vertically centered +- Title: "Sign in to your account" (login) / "Create your account" (register) +- Email + Password inputs (reuse existing input styles) +- Primary action button +- Divider: "── or continue with ──" +- Three OAuth buttons in a row: Google / GitHub / Apple (each with official SVG icon) +- Footer link: "Don't have an account? Sign up" / "Already have an account? Sign in" + +## OAuth Architecture + +### Flow (Standard Server-Side Redirect) + +``` +Browser clicks OAuth button + → GET /api/auth/oauth/:provider + → Server builds authorization URL with state param, 302 redirects to Provider + → User authorizes on Provider's page + → Provider redirects to GET /api/auth/oauth/:provider/callback?code=xxx&state=yyy + → Server validates state, exchanges code for access_token + → Server fetches user info (email, name, avatar) + → Server finds or creates user (see Account Linking below) + → Server issues JWT (accessToken + refreshToken) + → Server 302 redirects to frontend /login/callback?accessToken=xxx&refreshToken=xxx +``` + +### Account Linking Strategy + +On OAuth callback, the server resolves the user in this order: + +1. Look up `OAuthAccount` by `(provider, providerAccountId)` → if found, use linked `User` +2. If no OAuthAccount match, look up `User` by `email` → if found, create `OAuthAccount` linking to existing user +3. If no User match, create new `User` (passwordHash=null, name and avatarUrl from provider) + new `OAuthAccount` + +### Security + +- **CSRF protection**: Generate random `state` parameter per auth request, store in in-memory Map with 10-minute TTL, validate on callback +- **Token delivery**: Tokens passed via URL query params; frontend immediately consumes and clears URL +- **Secrets**: All client secrets stay server-side; no OAuth SDK loaded in frontend + +### Provider Configuration + +| Provider | Auth URL | Token URL | UserInfo URL | Scopes | +|----------|----------|-----------|--------------|--------| +| Google | accounts.google.com/o/oauth2/v2/auth | oauth2.googleapis.com/token | www.googleapis.com/oauth2/v2/userinfo | email, profile | +| GitHub | github.com/login/oauth/authorize | github.com/login/oauth/access_token | api.github.com/user + /user/emails | user:email | +| Apple | appleid.apple.com/auth/authorize | appleid.apple.com/auth/token | (decoded from id_token) | name, email | + +### Environment Variables + +```env +# Already in .env.example +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# New +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +OAUTH_CALLBACK_BASE_URL=http://localhost:3000 +``` + +### Frontend Callback Page (`/login/callback`) + +- Extracts `accessToken` and `refreshToken` from URL search params +- Stores in localStorage, updates AuthContext +- Redirects to `/dashboard` (or saved redirect target) +- Shows loading spinner during processing +- Shows error message with retry link on failure + +## File Changes + +### New Files + +| File | Purpose | +|------|---------| +| `packages/server/src/routes/oauth.ts` | OAuth routes (/:provider, /:provider/callback) | +| `packages/server/src/lib/oauth-providers.ts` | Provider configs + token exchange + userinfo fetch | +| `packages/web/src/pages/LoginCallback.tsx` | OAuth callback landing page | +| `packages/web/src/components/AuthBranding.tsx` | Shared left-panel brand component | +| `packages/web/src/components/OAuthButtons.tsx` | Third-party login button group | + +### Modified Files + +| File | Change | +|------|--------| +| `packages/server/src/index.ts` | Register `/auth/oauth` route | +| `packages/web/src/pages/Login.tsx` | Refactor to left-right split layout | +| `packages/web/src/pages/Register.tsx` | Refactor to left-right split layout | +| `packages/web/src/App.tsx` | Add `/login/callback` route | +| `packages/web/src/lib/i18n.tsx` | Add translation keys | +| `.env.example` | Add Apple OAuth env vars | + +### No Changes Needed + +- `prisma/schema.prisma` — OAuthAccount model already exists +- JWT signing logic — reuse existing `generateAccessToken`/`generateRefreshToken` +- Existing email/password auth — unchanged + +### No New Dependencies + +- OAuth token exchange: Node native `fetch` +- Apple JWT client_secret signing: Node `crypto` built-in +- No Passport.js, no OAuth libraries + +## User Action Required + +Before testing OAuth, the developer must register apps on each provider: + +- **Google**: Google Cloud Console → OAuth 2.0 Client → redirect URI: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/google/callback` +- **GitHub**: GitHub Developer Settings → OAuth App → callback URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/github/callback` +- **Apple**: Apple Developer → Services ID + Key → return URL: `{OAUTH_CALLBACK_BASE_URL}/auth/oauth/apple/callback` (requires HTTPS) + +Apple Sign In requires a paid Apple Developer account ($99/year) and HTTPS for callbacks. If unavailable, the Apple button can be displayed as "Coming Soon". From dace447a1447e273f7cc7ebd6b14b8652c2281a4 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:00:33 +0800 Subject: [PATCH 02/11] 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 +``` From 2d07ac6cd497b6f30401bb0fcc76c50cd05e9913 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:13:21 +0800 Subject: [PATCH 03/11] feat: add OAuth provider configuration and token exchange utilities --- packages/server/src/lib/oauth-providers.ts | 226 +++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 packages/server/src/lib/oauth-providers.ts diff --git a/packages/server/src/lib/oauth-providers.ts b/packages/server/src/lib/oauth-providers.ts new file mode 100644 index 0000000..712daad --- /dev/null +++ b/packages/server/src/lib/oauth-providers.ts @@ -0,0 +1,226 @@ +import crypto from 'node:crypto'; + +type ProviderConfig = { + authUrl: string; + tokenUrl: string; + userInfoUrl: string | null; + scopes: 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, + 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(); + +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, + }); + + 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}`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await res.json() as any; + + if (provider === 'apple') { + 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}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await res.json() as any; + + 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) { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (emailRes.ok) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const emails = await emailRes.json() as any[]; + 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 }; From 7f44bc8e32da9c5b00000a9a9787c0160b8bc694 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:15:38 +0800 Subject: [PATCH 04/11] feat: add loginWithTokens method to auth context for OAuth flow --- packages/web/src/lib/auth.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/web/src/lib/auth.tsx b/packages/web/src/lib/auth.tsx index 9c48e0a..47ae7f7 100644 --- a/packages/web/src/lib/auth.tsx +++ b/packages/web/src/lib/auth.tsx @@ -10,6 +10,7 @@ type AuthContextType = { register: (email: string, password: string, name: string) => Promise; logout: () => void; updateUser: (updates: Partial) => void; + loginWithTokens: (accessToken: string, refreshToken: string) => Promise; }; const AuthContext = createContext(null); @@ -53,8 +54,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(prev => prev ? { ...prev, ...updates } : null); }; + const loginWithTokens = async (access: string, refresh: string) => { + setTokens(access, refresh); + const user = await apiFetch('/auth/me'); + setUser(user); + }; + return ( - + {children} ); From 6d633eeac46aa534fa51778cd734c1676af38cba Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:15:42 +0800 Subject: [PATCH 05/11] feat: add i18n translation keys for auth pages --- packages/web/src/lib/i18n.tsx | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/web/src/lib/i18n.tsx b/packages/web/src/lib/i18n.tsx index 4164cda..309d0c5 100644 --- a/packages/web/src/lib/i18n.tsx +++ b/packages/web/src/lib/i18n.tsx @@ -17,6 +17,45 @@ const translations: AllTranslations = { 'nav.getStarted': 'Get Started', 'nav.dashboard': 'Dashboard', + // 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', + // Hero 'hero.badge': 'MCP-Powered API Intelligence', 'hero.title': 'API Docs for LLMs,', @@ -159,6 +198,45 @@ const translations: AllTranslations = { 'nav.getStarted': '免费开始', 'nav.dashboard': '控制台', + // 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': '重试', + // Hero 'hero.badge': 'MCP 驱动的 API 智能服务', 'hero.title': '为 LLM 而生的', From 0a48152e0fda94b2ec7472624a0a8f9e673db84b Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:15:56 +0800 Subject: [PATCH 06/11] feat: add AuthBranding and OAuthButtons components Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/AuthBranding.tsx | 49 ++++++++++++++++ packages/web/src/components/OAuthButtons.tsx | 60 ++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 packages/web/src/components/AuthBranding.tsx create mode 100644 packages/web/src/components/OAuthButtons.tsx diff --git a/packages/web/src/components/AuthBranding.tsx b/packages/web/src/components/AuthBranding.tsx new file mode 100644 index 0000000..de9679c --- /dev/null +++ b/packages/web/src/components/AuthBranding.tsx @@ -0,0 +1,49 @@ +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)} +
+ ))} +
+
+
+ ); +} diff --git a/packages/web/src/components/OAuthButtons.tsx b/packages/web/src/components/OAuthButtons.tsx new file mode 100644 index 0000000..0de29ee --- /dev/null +++ b/packages/web/src/components/OAuthButtons.tsx @@ -0,0 +1,60 @@ +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 }) => ( + + ))} +
+ ); +} From 9316795e4fa666d3dca702db1049fc853acd44f3 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:16:06 +0800 Subject: [PATCH 07/11] feat: add OAuth routes for Google, GitHub, and Apple login Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 ++ packages/server/src/index.ts | 3 + packages/server/src/routes/oauth.ts | 152 ++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 packages/server/src/routes/oauth.ts diff --git a/.env.example b/.env.example index adda320..ef7bfe1 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ 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 diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d94db1a..31616d4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,7 @@ 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'; @@ -9,12 +10,14 @@ 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); diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts new file mode 100644 index 0000000..0d8326b --- /dev/null +++ b/packages/server/src/routes/oauth.ts @@ -0,0 +1,152 @@ +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 { + 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; + } + + 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}`); + } 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; + } + + 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}`); + } 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) { + 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 }, + }); + 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; From a7027c8aaa3f9e92eb0d15bf8ebad9b1984dfdc2 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:17:53 +0800 Subject: [PATCH 08/11] feat: add LoginCallback page and route for OAuth redirect handling --- packages/web/src/App.tsx | 2 + packages/web/src/pages/LoginCallback.tsx | 67 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/web/src/pages/LoginCallback.tsx diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 009c22c..625b16b 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,6 +5,7 @@ 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'; @@ -22,6 +23,7 @@ export default function App() { } /> } /> } /> + } /> }> } /> } /> diff --git a/packages/web/src/pages/LoginCallback.tsx b/packages/web/src/pages/LoginCallback.tsx new file mode 100644 index 0000000..348879c --- /dev/null +++ b/packages/web/src/pages/LoginCallback.tsx @@ -0,0 +1,67 @@ +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')}

+
+
+ ); +} From db4e5540ad14adc28436726446a2fd89ddbf8cc4 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:18:05 +0800 Subject: [PATCH 09/11] feat: redesign login page with left-right split layout and OAuth buttons --- packages/web/src/pages/Login.tsx | 172 ++++++++++++++++++------------- 1 file changed, 99 insertions(+), 73 deletions(-) diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx index 86e99ea..e8e23ef 100644 --- a/packages/web/src/pages/Login.tsx +++ b/packages/web/src/pages/Login.tsx @@ -1,6 +1,9 @@ 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(''); @@ -11,7 +14,8 @@ export default function Login() { const { login } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const redirectTo = searchParams.get('redirect') || '/'; + const redirectTo = searchParams.get('redirect') || '/dashboard'; + const { t } = useI18n(); const validate = () => { const errors: { email?: string; password?: string } = {}; @@ -43,82 +47,104 @@ export default function Login() { }; return ( -
- {/* Subtle grid background */} -
- {/* Radial fade */} -
+
+ {/* Left panel — branding (hidden on mobile) */} + -
- {/* Brand */} -
-
- - - + {/* Right panel — form */} +
+ {/* Subtle grid background */} +
+
+ +
+ {/* Mobile-only brand (visible when left panel is hidden) */} +
+
+ + + + + +
+

{t('auth.productName')}

+

{t('auth.slogan')}

-

Sign in to AgentFox

-

API documentation for LLMs

-
- {/* 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} -

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

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

+
-

- Don't have an account?{' '} - Sign Up -

+ {/* 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')} +

+
); From 0bab0ecb93c25013a63249d87c324c955a402bda Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:18:07 +0800 Subject: [PATCH 10/11] feat: redesign register page with left-right split layout and OAuth buttons --- packages/web/src/pages/Register.tsx | 201 ++++++++++++++++------------ 1 file changed, 114 insertions(+), 87 deletions(-) diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx index a5c0ef1..b8ffad1 100644 --- a/packages/web/src/pages/Register.tsx +++ b/packages/web/src/pages/Register.tsx @@ -1,6 +1,9 @@ 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(''); @@ -11,6 +14,7 @@ export default function Register() { 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]) { @@ -20,9 +24,7 @@ export default function Register() { const validate = () => { const errors: { name?: string; email?: string; password?: string } = {}; - if (!name.trim()) { - errors.name = 'Name is required'; - } + if (!name.trim()) errors.name = 'Name is required'; if (!email.trim()) { errors.email = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { @@ -55,94 +57,119 @@ export default function Register() { 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')}

-

Create your account

-

Get started with AgentFox

-
-
- {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} -

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

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

+

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

+
-

- Already have an account?{' '} - Sign In -

+
+ {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')} +

+
); From eacaa5be0546003c9f24a654bb7676827cf15e6b Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Fri, 3 Apr 2026 13:25:50 +0800 Subject: [PATCH 11/11] refactor: simplify OAuth routes, add type safety, deduplicate UI components - Extract handleOAuthCallback to eliminate GET/POST duplication in oauth.ts - Add P2002 race condition handling in findOrCreateUser - Add .unref() to stateStore cleanup timer to not block process exit - Use Provider union type instead of bare strings throughout OAuth code - Export API_BASE from api.ts, reuse in OAuthButtons - Extract MobileBranding component to deduplicate Login/Register mobile brand - Extract shared Logo component in AuthBranding - Remove unnecessary WHAT comments Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/server/src/lib/oauth-providers.ts | 27 +++-- packages/server/src/routes/oauth.ts | 112 +++++++++---------- packages/web/src/components/AuthBranding.tsx | 35 ++++-- packages/web/src/components/OAuthButtons.tsx | 3 +- packages/web/src/lib/api.ts | 2 +- packages/web/src/pages/Login.tsx | 15 +-- packages/web/src/pages/Register.tsx | 15 +-- 7 files changed, 95 insertions(+), 114 deletions(-) diff --git a/packages/server/src/lib/oauth-providers.ts b/packages/server/src/lib/oauth-providers.ts index 712daad..d409506 100644 --- a/packages/server/src/lib/oauth-providers.ts +++ b/packages/server/src/lib/oauth-providers.ts @@ -14,7 +14,9 @@ type ProviderUser = { avatarUrl: string | null; }; -const providers: Record = { +export type Provider = 'google' | 'github' | 'apple'; + +const providers: Record = { google: { authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', @@ -35,14 +37,14 @@ const providers: Record = { }, }; -function getClientId(provider: string): string { +function getClientId(provider: Provider): 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 { +function getClientSecret(provider: Provider): string { if (provider === 'apple') return buildAppleClientSecret(); const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`; const value = process.env[envKey]; @@ -50,28 +52,28 @@ function getClientSecret(provider: string): string { return value; } -function getCallbackUrl(provider: string): string { +function getCallbackUrl(provider: Provider): 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(); -setInterval(() => { +const cleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, value] of stateStore) { if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key); } }, 5 * 60 * 1000); +cleanupTimer.unref(); -function generateState(provider: string): string { +function generateState(provider: Provider): string { const state = crypto.randomBytes(32).toString('hex'); stateStore.set(state, { provider, createdAt: Date.now() }); return state; } -function validateState(state: string, provider: string): boolean { +function validateState(state: string, provider: Provider): boolean { const entry = stateStore.get(state); if (!entry) return false; if (entry.provider !== provider) return false; @@ -83,7 +85,6 @@ function validateState(state: string, provider: string): boolean { return true; } -// --- Apple client_secret JWT --- function buildAppleClientSecret(): string { const teamId = process.env.APPLE_TEAM_ID; const keyId = process.env.APPLE_KEY_ID; @@ -105,9 +106,7 @@ function buildAppleClientSecret(): string { return `${signingInput}.${sig.toString('base64url')}`; } -// --- Public API --- - -export function buildAuthUrl(provider: string): string { +export function buildAuthUrl(provider: Provider): string { const config = providers[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); @@ -127,7 +126,7 @@ export function buildAuthUrl(provider: string): string { return `${config.authUrl}?${params.toString()}`; } -export async function exchangeCodeForToken(provider: string, code: string): Promise { +export async function exchangeCodeForToken(provider: Provider, code: string): Promise { const config = providers[provider]; if (!config) throw new Error(`Unknown provider: ${provider}`); @@ -163,7 +162,7 @@ export async function exchangeCodeForToken(provider: string, code: string): Prom return data.access_token as string; } -export async function fetchProviderUser(provider: string, token: string): Promise { +export async function fetchProviderUser(provider: Provider, token: string): Promise { if (provider === 'apple') { return parseAppleIdToken(token); } diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts index 0d8326b..1d7f2c3 100644 --- a/packages/server/src/routes/oauth.ts +++ b/packages/server/src/routes/oauth.ts @@ -1,17 +1,20 @@ -import { Router, type Router as RouterType } from 'express'; +import { Router, type Router as RouterType, type Response } from 'express'; import { prisma } from '@agent-fox/shared'; import { generateTokenPair } from '../lib/jwt.js'; -import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState } from '../lib/oauth-providers.js'; +import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState, type Provider } from '../lib/oauth-providers.js'; const router: RouterType = Router(); -const VALID_PROVIDERS = ['google', 'github', 'apple']; +const VALID_PROVIDERS: Provider[] = ['google', 'github', 'apple']; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; -// GET /auth/oauth/:provider — redirect to provider's authorization page +function isValidProvider(value: string): value is Provider { + return (VALID_PROVIDERS as string[]).includes(value); +} + router.get('/:provider', (req, res) => { const { provider } = req.params; - if (!VALID_PROVIDERS.includes(provider)) { + if (!isValidProvider(provider)) { res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } }); return; } @@ -24,17 +27,31 @@ router.get('/:provider', (req, res) => { } }); -// 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; + const params = req.query as Record; + await handleOAuthCallback(provider, params.code, params.state, params.error, res); +}); +// Apple sends callback as POST (form_post response mode) +router.post('/:provider/callback', async (req, res) => { + const { provider } = req.params; + await handleOAuthCallback(provider, req.body.code, req.body.state, req.body.error, res); +}); + +async function handleOAuthCallback( + provider: string, + code: string | undefined, + state: string | undefined, + oauthError: string | undefined, + res: Response, +) { if (oauthError) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`); return; } - if (!code || !state) { + if (!code || !state || !isValidProvider(provider)) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`); return; } @@ -60,51 +77,12 @@ router.get('/:provider/callback', async (req, res) => { 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; - } - - 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}`); - } 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 }, @@ -119,7 +97,6 @@ async function findOrCreateUser( 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({ @@ -134,19 +111,32 @@ async function findOrCreateUser( 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 }, + try { + 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; + }); + return newUser; + } catch (err: any) { + // Handle race condition: concurrent OAuth with same email + if (err?.code === 'P2002') { + const user = await prisma.user.findUnique({ where: { email: providerUser.email } }); + if (user) { + await prisma.oAuthAccount.create({ + data: { userId: user.id, provider, providerAccountId: providerUser.id }, + }).catch(() => {}); // Ignore if OAuthAccount also raced + return user; + } + } + throw err; + } } export default router; diff --git a/packages/web/src/components/AuthBranding.tsx b/packages/web/src/components/AuthBranding.tsx index de9679c..288e29a 100644 --- a/packages/web/src/components/AuthBranding.tsx +++ b/packages/web/src/components/AuthBranding.tsx @@ -1,36 +1,51 @@ import { useI18n } from '../lib/i18n'; +function Logo({ className }: { className: string }) { + return ( + + + + + + ); +} + +export function MobileBranding() { + const { t } = useI18n(); + + return ( +
+
+ +
+

{t('auth.productName')}

+

{t('auth.slogan')}

+
+ ); +} + 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) => (
diff --git a/packages/web/src/components/OAuthButtons.tsx b/packages/web/src/components/OAuthButtons.tsx index 0de29ee..9a2014a 100644 --- a/packages/web/src/components/OAuthButtons.tsx +++ b/packages/web/src/components/OAuthButtons.tsx @@ -1,6 +1,5 @@ import { useI18n } from '../lib/i18n'; - -const API_BASE = '/api'; +import { API_BASE } from '../lib/api'; function GoogleIcon() { return ( diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index f94c351..18c9f3b 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -1,4 +1,4 @@ -const API_BASE = '/api'; +export const API_BASE = '/api'; type ApiResponse = { success: boolean; diff --git a/packages/web/src/pages/Login.tsx b/packages/web/src/pages/Login.tsx index e8e23ef..d76f608 100644 --- a/packages/web/src/pages/Login.tsx +++ b/packages/web/src/pages/Login.tsx @@ -2,7 +2,7 @@ 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 AuthBranding, { MobileBranding } from '../components/AuthBranding'; import OAuthButtons from '../components/OAuthButtons'; export default function Login() { @@ -63,18 +63,7 @@ export default function Login() { }} />
- {/* Mobile-only brand (visible when left panel is hidden) */} -
-
- - - - - -
-

{t('auth.productName')}

-

{t('auth.slogan')}

-
+ {/* Title (desktop) */}
diff --git a/packages/web/src/pages/Register.tsx b/packages/web/src/pages/Register.tsx index b8ffad1..a4f74a3 100644 --- a/packages/web/src/pages/Register.tsx +++ b/packages/web/src/pages/Register.tsx @@ -2,7 +2,7 @@ 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 AuthBranding, { MobileBranding } from '../components/AuthBranding'; import OAuthButtons from '../components/OAuthButtons'; export default function Register() { @@ -72,18 +72,7 @@ export default function Register() { }} />
- {/* Mobile-only brand */} -
-
- - - - - -
-

{t('auth.productName')}

-

{t('auth.slogan')}

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