feat: 登录页重设计 + Google/GitHub/Apple OAuth 登录支持
- 登录/注册页改为左右分栏布局(左侧品牌展示,右侧表单) - 新增 Google、GitHub、Apple 三方 OAuth 登录(标准授权码流程) - 后端:OAuth 路由、Provider 配置、用户查找/创建逻辑 - 前端:AuthBranding、OAuthButtons、LoginCallback 组件 - 移动端自适应(品牌区隐藏,显示简版标题) - 完整中英双语 i18n 支持 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,3 +10,9 @@ SERVER_PORT=3000
|
|||||||
MCP_PORT=3001
|
MCP_PORT=3001
|
||||||
WEB_PORT=5173
|
WEB_PORT=5173
|
||||||
REDIS_URL=redis://localhost:6379
|
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
|
||||||
|
|||||||
1468
docs/superpowers/plans/2026-04-03-login-page-oauth.md
Normal file
1468
docs/superpowers/plans/2026-04-03-login-page-oauth.md
Normal file
File diff suppressed because it is too large
Load Diff
144
docs/superpowers/specs/2026-04-03-login-page-oauth-design.md
Normal file
144
docs/superpowers/specs/2026-04-03-login-page-oauth-design.md
Normal file
@@ -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".
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
import oauthRouter from './routes/oauth.js';
|
||||||
import projectRouter from './routes/projects.js';
|
import projectRouter from './routes/projects.js';
|
||||||
import importRouter from './routes/import.js';
|
import importRouter from './routes/import.js';
|
||||||
import moduleRouter from './routes/modules.js';
|
import moduleRouter from './routes/modules.js';
|
||||||
@@ -9,12 +10,14 @@ import endpointRouter from './routes/endpoints.js';
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, data: { status: 'ok' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/auth/oauth', oauthRouter);
|
||||||
app.use('/api/projects', projectRouter);
|
app.use('/api/projects', projectRouter);
|
||||||
app.use('/api/projects', importRouter);
|
app.use('/api/projects', importRouter);
|
||||||
app.use('/api/projects', moduleRouter);
|
app.use('/api/projects', moduleRouter);
|
||||||
|
|||||||
225
packages/server/src/lib/oauth-providers.ts
Normal file
225
packages/server/src/lib/oauth-providers.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Provider = 'google' | 'github' | 'apple';
|
||||||
|
|
||||||
|
const providers: Record<Provider, 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,
|
||||||
|
scopes: ['name', 'email'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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: Provider): 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: Provider): string {
|
||||||
|
const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000';
|
||||||
|
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
||||||
|
|
||||||
|
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: Provider): string {
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
stateStore.set(state, { provider, createdAt: Date.now() });
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateState(state: string, provider: Provider): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthUrl(provider: Provider): 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: Provider, 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: Provider, 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}`);
|
||||||
|
// 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 };
|
||||||
142
packages/server/src/routes/oauth.ts
Normal file
142
packages/server/src/routes/oauth.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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, type Provider } from '../lib/oauth-providers.js';
|
||||||
|
|
||||||
|
const router: RouterType = Router();
|
||||||
|
|
||||||
|
const VALID_PROVIDERS: Provider[] = ['google', 'github', 'apple'];
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
function isValidProvider(value: string): value is Provider {
|
||||||
|
return (VALID_PROVIDERS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:provider', (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
if (!isValidProvider(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' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:provider/callback', async (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const params = req.query as Record<string, string>;
|
||||||
|
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 || !isValidProvider(provider)) {
|
||||||
|
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')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateUser(
|
||||||
|
provider: string,
|
||||||
|
providerUser: { id: string; email: string; name: string; avatarUrl: string | null },
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
} 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;
|
||||||
@@ -5,6 +5,7 @@ import { ThemeProvider } from './lib/theme';
|
|||||||
import { I18nProvider } from './lib/i18n';
|
import { I18nProvider } from './lib/i18n';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
import LoginCallback from './pages/LoginCallback';
|
||||||
import Layout from './pages/Layout';
|
import Layout from './pages/Layout';
|
||||||
import Projects from './pages/Projects';
|
import Projects from './pages/Projects';
|
||||||
import ProjectDetail from './pages/ProjectDetail';
|
import ProjectDetail from './pages/ProjectDetail';
|
||||||
@@ -22,6 +23,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/login/callback" element={<LoginCallback />} />
|
||||||
<Route path="/dashboard" element={<Layout />}>
|
<Route path="/dashboard" element={<Layout />}>
|
||||||
<Route index element={<Projects />} />
|
<Route index element={<Projects />} />
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
|
|||||||
64
packages/web/src/components/AuthBranding.tsx
Normal file
64
packages/web/src/components/AuthBranding.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useI18n, tk } from '../lib/i18n';
|
||||||
|
|
||||||
|
function Logo({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBranding() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Logo className="w-5 h-5 text-white" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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))' }}>
|
||||||
|
<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">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center mb-8 shadow-lg">
|
||||||
|
<Logo className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
||||||
|
{t('auth.productName')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-white/90 mb-10 leading-relaxed">
|
||||||
|
{t('auth.slogan')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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(tk(key))}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
packages/web/src/components/OAuthButtons.tsx
Normal file
59
packages/web/src/components/OAuthButtons.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { API_BASE } from '../lib/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = '/api';
|
export const API_BASE = '/api';
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type AuthContextType = {
|
|||||||
register: (email: string, password: string, name: string) => Promise<void>;
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
updateUser: (updates: Partial<User>) => void;
|
updateUser: (updates: Partial<User>) => void;
|
||||||
|
loginWithTokens: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@@ -53,8 +54,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUser(prev => prev ? { ...prev, ...updates } : null);
|
setUser(prev => prev ? { ...prev, ...updates } : null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loginWithTokens = async (access: string, refresh: string) => {
|
||||||
|
setTokens(access, refresh);
|
||||||
|
const user = await apiFetch<User>('/auth/me');
|
||||||
|
setUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser, loginWithTokens }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,6 +185,14 @@ const en = {
|
|||||||
'auth.login.emailInvalid': 'Please enter a valid email address',
|
'auth.login.emailInvalid': 'Please enter a valid email address',
|
||||||
'auth.login.passwordRequired': 'Password is required',
|
'auth.login.passwordRequired': 'Password is required',
|
||||||
'auth.login.passwordPlaceholder': 'Enter your password',
|
'auth.login.passwordPlaceholder': 'Enter your password',
|
||||||
|
'auth.login.or': 'or continue with',
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'auth.register.title': 'Create your account',
|
'auth.register.title': 'Create your account',
|
||||||
@@ -203,6 +211,17 @@ const en = {
|
|||||||
'auth.register.passwordMin': 'Password must be at least 8 characters',
|
'auth.register.passwordMin': 'Password must be at least 8 characters',
|
||||||
'auth.register.namePlaceholder': 'Your name',
|
'auth.register.namePlaceholder': 'Your name',
|
||||||
'auth.register.passwordPlaceholder': 'At least 8 characters',
|
'auth.register.passwordPlaceholder': 'At least 8 characters',
|
||||||
|
'auth.register.or': 'or continue with',
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
'auth.oauth.google': 'Google',
|
||||||
|
'auth.oauth.github': 'GitHub',
|
||||||
|
'auth.oauth.apple': 'Apple',
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
'auth.callback.loading': 'Completing sign in...',
|
||||||
|
'auth.callback.error': 'Sign in failed',
|
||||||
|
'auth.callback.retry': 'Try again',
|
||||||
|
|
||||||
// ===== Dashboard Layout =====
|
// ===== Dashboard Layout =====
|
||||||
'dashboard.layout.projects': 'Projects',
|
'dashboard.layout.projects': 'Projects',
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ const zh: Record<TranslationKey, string> = {
|
|||||||
'auth.login.emailInvalid': '请输入有效的邮箱地址',
|
'auth.login.emailInvalid': '请输入有效的邮箱地址',
|
||||||
'auth.login.passwordRequired': '请输入密码',
|
'auth.login.passwordRequired': '请输入密码',
|
||||||
'auth.login.passwordPlaceholder': '输入你的密码',
|
'auth.login.passwordPlaceholder': '输入你的密码',
|
||||||
|
'auth.login.or': '或者通过以下方式继续',
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
'auth.productName': 'AgentFox',
|
||||||
|
'auth.slogan': 'LLM 专属 API 文档方案',
|
||||||
|
'auth.feature1': '多级 API 检索,最小化 Token 消耗',
|
||||||
|
'auth.feature2': '秒级导入 OpenAPI 文档',
|
||||||
|
'auth.feature3': '兼容所有 MCP 协议的 LLM 工具',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'auth.register.title': '创建账号',
|
'auth.register.title': '创建账号',
|
||||||
@@ -205,6 +213,17 @@ const zh: Record<TranslationKey, string> = {
|
|||||||
'auth.register.passwordMin': '密码至少需要 8 个字符',
|
'auth.register.passwordMin': '密码至少需要 8 个字符',
|
||||||
'auth.register.namePlaceholder': '你的姓名',
|
'auth.register.namePlaceholder': '你的姓名',
|
||||||
'auth.register.passwordPlaceholder': '至少 8 个字符',
|
'auth.register.passwordPlaceholder': '至少 8 个字符',
|
||||||
|
'auth.register.or': '或者通过以下方式继续',
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
'auth.oauth.google': 'Google',
|
||||||
|
'auth.oauth.github': 'GitHub',
|
||||||
|
'auth.oauth.apple': 'Apple',
|
||||||
|
|
||||||
|
// Callback
|
||||||
|
'auth.callback.loading': '正在完成登录...',
|
||||||
|
'auth.callback.error': '登录失败',
|
||||||
|
'auth.callback.retry': '重试',
|
||||||
|
|
||||||
// ===== Dashboard Layout =====
|
// ===== Dashboard Layout =====
|
||||||
'dashboard.layout.projects': '项目',
|
'dashboard.layout.projects': '项目',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||||
|
import OAuthButtons from '../components/OAuthButtons';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -12,7 +14,7 @@ export default function Login() {
|
|||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const redirectTo = searchParams.get('redirect') || '/';
|
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
@@ -45,82 +47,86 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
<div className="min-h-screen flex">
|
||||||
{/* Subtle grid background */}
|
<AuthBranding />
|
||||||
<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',
|
|
||||||
}} />
|
|
||||||
{/* Radial fade */}
|
|
||||||
<div className="absolute inset-0" style={{
|
|
||||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
|
||||||
}} />
|
|
||||||
|
|
||||||
<div className="w-full max-w-[360px] mx-4 relative animate-slide-up">
|
<div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
|
||||||
{/* Brand */}
|
<div className="absolute inset-0" style={{
|
||||||
<div className="text-center mb-8">
|
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
backgroundSize: '48px 48px',
|
||||||
<svg className="w-5 h-5 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" />
|
<div className="absolute inset-0" style={{
|
||||||
</svg>
|
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 70%)`,
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||||
|
<MobileBranding />
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">{t('auth.login.title')}</h1>
|
|
||||||
<p className="text-[13px] text-text-muted mt-1">{t('auth.login.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card */}
|
<div className="card p-6 shadow-md">
|
||||||
<div className="card p-6 shadow-md">
|
{error && (
|
||||||
{error && (
|
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
||||||
<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>
|
||||||
<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>
|
||||||
<span className="text-danger text-[13px]">{error}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<div>
|
||||||
<div>
|
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.email')}</label>
|
||||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.email')}</label>
|
<input
|
||||||
<input
|
type="email"
|
||||||
type="email"
|
value={email}
|
||||||
value={email}
|
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
|
||||||
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)]!' : ''}`}
|
||||||
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
placeholder="you@example.com"
|
||||||
placeholder="you@example.com"
|
/>
|
||||||
/>
|
{fieldErrors.email && (
|
||||||
{fieldErrors.email && (
|
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||||
<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>
|
||||||
<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}
|
||||||
{fieldErrors.email}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.password')}</label>
|
||||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.login.password')}</label>
|
<input
|
||||||
<input
|
type="password"
|
||||||
type="password"
|
value={password}
|
||||||
value={password}
|
onChange={(e) => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }}
|
||||||
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)]!' : ''}`}
|
||||||
className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
placeholder={t('auth.login.passwordPlaceholder')}
|
||||||
placeholder={t('auth.login.passwordPlaceholder')}
|
/>
|
||||||
/>
|
{fieldErrors.password && (
|
||||||
{fieldErrors.password && (
|
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||||
<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>
|
||||||
<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}
|
||||||
{fieldErrors.password}
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
{loading ? (
|
||||||
{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')}</>
|
||||||
<><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')}
|
||||||
) : t('auth.login.submit')}
|
</button>
|
||||||
</button>
|
</form>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
<div className="flex items-center gap-3 my-5">
|
||||||
{t('auth.login.noAccount')}{' '}
|
<div className="flex-1 h-px bg-border-default" />
|
||||||
<Link to="/register" className="text-accent hover:underline font-medium">{t('auth.login.signUp')}</Link>
|
<span className="text-[12px] text-text-muted">{t('auth.login.or')}</span>
|
||||||
</p>
|
<div className="flex-1 h-px bg-border-default" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
67
packages/web/src/pages/LoginCallback.tsx
Normal file
67
packages/web/src/pages/LoginCallback.tsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||||
|
import OAuthButtons from '../components/OAuthButtons';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -57,94 +59,103 @@ export default function Register() {
|
|||||||
const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!';
|
const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
<div className="min-h-screen flex">
|
||||||
<div className="absolute inset-0" style={{
|
<AuthBranding />
|
||||||
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-[360px] mx-4 relative animate-slide-up">
|
<div className="flex-1 flex items-center justify-center p-6 bg-bg-primary relative overflow-hidden">
|
||||||
<div className="text-center mb-8">
|
<div className="absolute inset-0" style={{
|
||||||
<div className="w-11 h-11 rounded-xl bg-accent mx-auto flex items-center justify-center mb-4 shadow-md">
|
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||||
<svg className="w-5 h-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
backgroundSize: '48px 48px',
|
||||||
<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 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">
|
||||||
|
<MobileBranding />
|
||||||
|
|
||||||
|
<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>
|
||||||
<h1 className="text-xl 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">
|
<div className="card p-6 shadow-md">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 rounded-lg bg-danger-muted flex items-center gap-2">
|
<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>
|
<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>
|
<span className="text-danger text-[13px]">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.name')}</label>
|
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.name')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
||||||
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
||||||
placeholder={t('auth.register.namePlaceholder')}
|
placeholder={t('auth.register.namePlaceholder')}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.name && (
|
{fieldErrors.name && (
|
||||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
<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>
|
<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}
|
{fieldErrors.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.email')}</label>
|
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.email')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
|
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
|
||||||
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
|
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && (
|
{fieldErrors.email && (
|
||||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
<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>
|
<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}
|
{fieldErrors.email}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.password')}</label>
|
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">{t('auth.register.password')}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
||||||
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
||||||
placeholder={t('auth.register.passwordPlaceholder')}
|
placeholder={t('auth.register.passwordPlaceholder')}
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && (
|
{fieldErrors.password && (
|
||||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
<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>
|
<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}
|
{fieldErrors.password}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||||
{loading ? (
|
{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')}</>
|
<><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')}
|
) : t('auth.register.submit')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
<div className="flex items-center gap-3 my-5">
|
||||||
{t('auth.register.hasAccount')}{' '}
|
<div className="flex-1 h-px bg-border-default" />
|
||||||
<Link to="/login" className="text-accent hover:underline font-medium">{t('auth.register.signIn')}</Link>
|
<span className="text-[12px] text-text-muted">{t('auth.register.or')}</span>
|
||||||
</p>
|
<div className="flex-1 h-px bg-border-default" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user