Files
agent-fox/docs/superpowers/plans/2026-04-03-login-page-oauth.md
2026-04-03 13:00:33 +08:00

52 KiB

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
// 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<string, ProviderConfig> = {
  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<string, { provider: string; createdAt: number }>();

// 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<string> {
  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<ProviderUser> {
  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
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

// 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<string, string>;

  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)
// 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:

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
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:

loginWithTokens: (accessToken: string, refreshToken: string) => Promise<void>;

Add the implementation inside AuthProvider, after the register function:

const loginWithTokens = async (access: string, refresh: string) => {
  setTokens(access, refresh);
  const user = await apiFetch<User>('/auth/me');
  setUser(user);
};

Update the Provider value to include loginWithTokens:

<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser, loginWithTokens }}>

The full file becomes:

// 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<void>;
  register: (email: string, password: string, name: string) => Promise<void>;
  loginWithTokens: (accessToken: string, refreshToken: string) => Promise<void>;
  logout: () => void;
  updateUser: (updates: Partial<User>) => void;
};

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (getAccessToken()) {
      apiFetch<User>('/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<User>('/auth/me');
    setUser(user);
  };

  const logout = () => { clearTokens(); setUser(null); };

  const updateUser = (updates: Partial<User>) => {
    setUser(prev => prev ? { ...prev, ...updates } : null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, register, loginWithTokens, logout, updateUser }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}
  • Step 2: Commit
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):

// 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:

// 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
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
// packages/web/src/components/AuthBranding.tsx
import { useI18n } from '../lib/i18n';

export default function AuthBranding() {
  const { t } = useI18n();

  return (
    <div className="hidden lg:flex lg:w-1/2 relative overflow-hidden items-center justify-center p-12"
      style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
      {/* Decorative circles */}
      <div className="absolute -top-24 -left-24 w-96 h-96 rounded-full opacity-10 bg-white" />
      <div className="absolute -bottom-32 -right-32 w-[500px] h-[500px] rounded-full opacity-10 bg-white" />

      <div className="relative z-10 max-w-md text-white">
        {/* Logo */}
        <div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center mb-8 shadow-lg">
          <svg className="w-10 h-10 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 2L2 7l10 5 10-5-10-5z" />
            <path d="M2 17l10 5 10-5" />
            <path d="M2 12l10 5 10-5" />
          </svg>
        </div>

        {/* Product name */}
        <h1 className="text-4xl font-bold tracking-tight mb-3">
          {t('auth.productName')}
        </h1>

        {/* Slogan */}
        <p className="text-xl text-white/90 mb-10 leading-relaxed">
          {t('auth.slogan')}
        </p>

        {/* Feature highlights */}
        <div className="space-y-4">
          {['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => (
            <div key={key} className="flex items-start gap-3">
              <div className="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
                <svg className="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor">
                  <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
                </svg>
              </div>
              <span className="text-white/90 text-[15px] leading-snug">{t(key)}</span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
  • Step 2: Commit
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

// packages/web/src/components/OAuthButtons.tsx
import { useI18n } from '../lib/i18n';

const API_BASE = '/api';

function GoogleIcon() {
  return (
    <svg className="w-5 h-5" viewBox="0 0 24 24">
      <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
      <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
      <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
      <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
    </svg>
  );
}

function GitHubIcon() {
  return (
    <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
      <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
    </svg>
  );
}

function AppleIcon() {
  return (
    <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
      <path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
    </svg>
  );
}

export default function OAuthButtons() {
  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 (
    <div className="flex gap-3">
      {buttons.map(({ provider, icon: Icon, label }) => (
        <button
          key={provider}
          type="button"
          onClick={() => handleOAuth(provider)}
          className="flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg border border-border-default bg-bg-primary hover:bg-bg-secondary transition-colors text-[13px] font-medium text-text-secondary cursor-pointer"
        >
          <Icon />
          <span>{label}</span>
        </button>
      ))}
    </div>
  );
}
  • Step 2: Commit
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

// 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 (
      <div className="min-h-screen flex items-center justify-center bg-bg-primary">
        <div className="text-center max-w-sm mx-4">
          <div className="w-12 h-12 rounded-full bg-danger-muted mx-auto flex items-center justify-center mb-4">
            <svg className="w-6 h-6 text-danger" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
              <circle cx="12" cy="12" r="10" />
              <path d="M15 9l-6 6m0-6l6 6" />
            </svg>
          </div>
          <h1 className="text-lg font-semibold text-text-primary mb-2">{t('auth.callback.error')}</h1>
          <p className="text-[13px] text-text-muted mb-6">{error}</p>
          <Link to="/login" className="btn-primary inline-block px-6">
            {t('auth.callback.retry')}
          </Link>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-bg-primary">
      <div className="text-center">
        <svg className="w-8 h-8 animate-spin text-accent mx-auto mb-4" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
        </svg>
        <p className="text-[13px] text-text-muted">{t('auth.callback.loading')}</p>
      </div>
    </div>
  );
}
  • Step 2: Add route to App.tsx

In packages/web/src/App.tsx:

Add import at the top:

import LoginCallback from './pages/LoginCallback';

Add route after the /register route:

<Route path="/login/callback" element={<LoginCallback />} />

The full file becomes:

// 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 (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>
        <AuthProvider>
          <I18nProvider>
            <BrowserRouter>
              <Routes>
                <Route path="/" element={<LandingPage />} />
                <Route path="/login" element={<Login />} />
                <Route path="/register" element={<Register />} />
                <Route path="/login/callback" element={<LoginCallback />} />
                <Route path="/dashboard" element={<Layout />}>
                  <Route index element={<Projects />} />
                  <Route path="projects/:id" element={<ProjectDetail />} />
                </Route>
                <Route path="*" element={<Navigate to="/" replace />} />
              </Routes>
            </BrowserRouter>
          </I18nProvider>
        </AuthProvider>
      </ThemeProvider>
    </QueryClientProvider>
  );
}
  • Step 3: Commit
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
// 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 (
    <div className="min-h-screen flex">
      {/* Left panel — branding (hidden on mobile) */}
      <AuthBranding />

      {/* Right panel — form */}
      <div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
        {/* Subtle grid background */}
        <div className="absolute inset-0" style={{
          backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
          backgroundSize: '48px 48px',
        }} />
        <div className="absolute inset-0" style={{
          background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
        }} />

        <div className="w-full max-w-[400px] relative animate-slide-up">
          {/* Mobile-only brand (visible when left panel is hidden) */}
          <div className="lg:hidden text-center mb-8">
            <div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
              <svg className="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 2L2 7l10 5 10-5-10-5z" />
                <path d="M2 17l10 5 10-5" />
                <path d="M2 12l10 5 10-5" />
              </svg>
            </div>
            <h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.productName')}</h1>
            <p className="text-[13px] text-text-muted mt-1">{t('auth.slogan')}</p>
          </div>

          {/* Title (desktop) */}
          <div className="hidden lg:block mb-8">
            <h1 className="text-2xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.login.title')}</h1>
          </div>

          {/* Card */}
          <div className="card p-6 shadow-md">
            {error && (
              <div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
                <svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
                <span className="text-danger text-[13px]">{error}</span>
              </div>
            )}
            <form onSubmit={handleSubmit} noValidate className="space-y-4">
              <div>
                <label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.email')}</label>
                <input
                  type="email"
                  value={email}
                  onChange={(e) => { 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 && (
                  <p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
                    <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
                    {fieldErrors.email}
                  </p>
                )}
              </div>
              <div>
                <label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.password')}</label>
                <input
                  type="password"
                  value={password}
                  onChange={(e) => { 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 && (
                  <p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
                    <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
                    {fieldErrors.password}
                  </p>
                )}
              </div>
              <button type="submit" disabled={loading} className="btn-primary w-full">
                {loading ? (
                  <><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('auth.login.submitting')}</>
                ) : t('auth.login.submit')}
              </button>
            </form>

            {/* Divider */}
            <div className="flex items-center gap-3 my-5">
              <div className="flex-1 h-px bg-border-default" />
              <span className="text-[12px] text-text-muted">{t('auth.login.or')}</span>
              <div className="flex-1 h-px bg-border-default" />
            </div>

            {/* OAuth buttons */}
            <OAuthButtons />
          </div>

          <p className="text-center text-[13px] text-text-muted mt-6">
            {t('auth.login.noAccount')}{' '}
            <Link to="/register" className="text-accent hover:underline font-medium">{t('auth.login.signUp')}</Link>
          </p>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Verify page renders

Run: cd packages/web && npx tsc --noEmit 2>&1 | head -20 Expected: No type errors

  • Step 3: Commit
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
// 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 (
    <div className="min-h-screen flex">
      {/* Left panel — branding (hidden on mobile) */}
      <AuthBranding />

      {/* Right panel — form */}
      <div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
        <div className="absolute inset-0" style={{
          backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
          backgroundSize: '48px 48px',
        }} />
        <div className="absolute inset-0" style={{
          background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
        }} />

        <div className="w-full max-w-[400px] relative animate-slide-up">
          {/* Mobile-only brand */}
          <div className="lg:hidden text-center mb-8">
            <div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
              <svg className="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 2L2 7l10 5 10-5-10-5z" />
                <path d="M2 17l10 5 10-5" />
                <path d="M2 12l10 5 10-5" />
              </svg>
            </div>
            <h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.productName')}</h1>
            <p className="text-[13px] text-text-muted mt-1">{t('auth.slogan')}</p>
          </div>

          {/* Title (desktop) */}
          <div className="hidden lg:block mb-8">
            <h1 className="text-2xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.register.title')}</h1>
            <p className="text-[13px] text-text-muted mt-1">{t('auth.register.subtitle')}</p>
          </div>

          <div className="card p-6 shadow-md">
            {error && (
              <div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
                <svg className="w-4 h-4 text-danger shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6m0-6l6 6" /></svg>
                <span className="text-danger text-[13px]">{error}</span>
              </div>
            )}
            <form onSubmit={handleSubmit} noValidate className="space-y-4">
              <div>
                <label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.name')}</label>
                <input
                  type="text"
                  value={name}
                  onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
                  className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
                  placeholder="Your name"
                />
                {fieldErrors.name && (
                  <p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
                    <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
                    {fieldErrors.name}
                  </p>
                )}
              </div>
              <div>
                <label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.email')}</label>
                <input
                  type="email"
                  value={email}
                  onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
                  className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
                  placeholder="you@example.com"
                />
                {fieldErrors.email && (
                  <p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
                    <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
                    {fieldErrors.email}
                  </p>
                )}
              </div>
              <div>
                <label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.password')}</label>
                <input
                  type="password"
                  value={password}
                  onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
                  className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
                  placeholder="At least 8 characters"
                />
                {fieldErrors.password && (
                  <p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
                    <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
                    {fieldErrors.password}
                  </p>
                )}
              </div>
              <button type="submit" disabled={loading} className="btn-primary w-full">
                {loading ? (
                  <><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('auth.register.submitting')}</>
                ) : t('auth.register.submit')}
              </button>
            </form>

            {/* Divider */}
            <div className="flex items-center gap-3 my-5">
              <div className="flex-1 h-px bg-border-default" />
              <span className="text-[12px] text-text-muted">{t('auth.register.or')}</span>
              <div className="flex-1 h-px bg-border-default" />
            </div>

            {/* OAuth buttons */}
            <OAuthButtons />
          </div>

          <p className="text-center text-[13px] text-text-muted mt-6">
            {t('auth.register.hasAccount')}{' '}
            <Link to="/login" className="text-accent hover:underline font-medium">{t('auth.register.signIn')}</Link>
          </p>
        </div>
      </div>
    </div>
  );
}
  • Step 2: Verify page compiles

Run: cd packages/web && npx tsc --noEmit 2>&1 | head -20 Expected: No type errors

  • Step 3: Commit
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

git push -u origin feature/login-page