refactor: simplify OAuth routes, add type safety, deduplicate UI components
- Extract handleOAuthCallback to eliminate GET/POST duplication in oauth.ts - Add P2002 race condition handling in findOrCreateUser - Add .unref() to stateStore cleanup timer to not block process exit - Use Provider union type instead of bare strings throughout OAuth code - Export API_BASE from api.ts, reuse in OAuthButtons - Extract MobileBranding component to deduplicate Login/Register mobile brand - Extract shared Logo component in AuthBranding - Remove unnecessary WHAT comments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,9 @@ type ProviderUser = {
|
|||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const providers: Record<string, ProviderConfig> = {
|
export type Provider = 'google' | 'github' | 'apple';
|
||||||
|
|
||||||
|
const providers: Record<Provider, ProviderConfig> = {
|
||||||
google: {
|
google: {
|
||||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
tokenUrl: 'https://oauth2.googleapis.com/token',
|
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||||
@@ -35,14 +37,14 @@ const providers: Record<string, ProviderConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function getClientId(provider: string): string {
|
function getClientId(provider: Provider): string {
|
||||||
const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`;
|
const envKey = provider === 'apple' ? 'APPLE_CLIENT_ID' : `${provider.toUpperCase()}_CLIENT_ID`;
|
||||||
const value = process.env[envKey];
|
const value = process.env[envKey];
|
||||||
if (!value) throw new Error(`Missing env: ${envKey}`);
|
if (!value) throw new Error(`Missing env: ${envKey}`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientSecret(provider: string): string {
|
function getClientSecret(provider: Provider): string {
|
||||||
if (provider === 'apple') return buildAppleClientSecret();
|
if (provider === 'apple') return buildAppleClientSecret();
|
||||||
const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`;
|
const envKey = `${provider.toUpperCase()}_CLIENT_SECRET`;
|
||||||
const value = process.env[envKey];
|
const value = process.env[envKey];
|
||||||
@@ -50,28 +52,28 @@ function getClientSecret(provider: string): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCallbackUrl(provider: string): string {
|
function getCallbackUrl(provider: Provider): string {
|
||||||
const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000';
|
const base = process.env.OAUTH_CALLBACK_BASE_URL || 'http://localhost:3000';
|
||||||
return `${base}/api/auth/oauth/${provider}/callback`;
|
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- State management (CSRF protection) ---
|
|
||||||
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
||||||
|
|
||||||
setInterval(() => {
|
const cleanupTimer = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, value] of stateStore) {
|
for (const [key, value] of stateStore) {
|
||||||
if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key);
|
if (now - value.createdAt > 10 * 60 * 1000) stateStore.delete(key);
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
cleanupTimer.unref();
|
||||||
|
|
||||||
function generateState(provider: string): string {
|
function generateState(provider: Provider): string {
|
||||||
const state = crypto.randomBytes(32).toString('hex');
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
stateStore.set(state, { provider, createdAt: Date.now() });
|
stateStore.set(state, { provider, createdAt: Date.now() });
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateState(state: string, provider: string): boolean {
|
function validateState(state: string, provider: Provider): boolean {
|
||||||
const entry = stateStore.get(state);
|
const entry = stateStore.get(state);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (entry.provider !== provider) return false;
|
if (entry.provider !== provider) return false;
|
||||||
@@ -83,7 +85,6 @@ function validateState(state: string, provider: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Apple client_secret JWT ---
|
|
||||||
function buildAppleClientSecret(): string {
|
function buildAppleClientSecret(): string {
|
||||||
const teamId = process.env.APPLE_TEAM_ID;
|
const teamId = process.env.APPLE_TEAM_ID;
|
||||||
const keyId = process.env.APPLE_KEY_ID;
|
const keyId = process.env.APPLE_KEY_ID;
|
||||||
@@ -105,9 +106,7 @@ function buildAppleClientSecret(): string {
|
|||||||
return `${signingInput}.${sig.toString('base64url')}`;
|
return `${signingInput}.${sig.toString('base64url')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Public API ---
|
export function buildAuthUrl(provider: Provider): string {
|
||||||
|
|
||||||
export function buildAuthUrl(provider: string): string {
|
|
||||||
const config = providers[provider];
|
const config = providers[provider];
|
||||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ export function buildAuthUrl(provider: string): string {
|
|||||||
return `${config.authUrl}?${params.toString()}`;
|
return `${config.authUrl}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exchangeCodeForToken(provider: string, code: string): Promise<string> {
|
export async function exchangeCodeForToken(provider: Provider, code: string): Promise<string> {
|
||||||
const config = providers[provider];
|
const config = providers[provider];
|
||||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ export async function exchangeCodeForToken(provider: string, code: string): Prom
|
|||||||
return data.access_token as string;
|
return data.access_token as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProviderUser(provider: string, token: string): Promise<ProviderUser> {
|
export async function fetchProviderUser(provider: Provider, token: string): Promise<ProviderUser> {
|
||||||
if (provider === 'apple') {
|
if (provider === 'apple') {
|
||||||
return parseAppleIdToken(token);
|
return parseAppleIdToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Router, type Router as RouterType } from 'express';
|
import { Router, type Router as RouterType, type Response } from 'express';
|
||||||
import { prisma } from '@agent-fox/shared';
|
import { prisma } from '@agent-fox/shared';
|
||||||
import { generateTokenPair } from '../lib/jwt.js';
|
import { generateTokenPair } from '../lib/jwt.js';
|
||||||
import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState } from '../lib/oauth-providers.js';
|
import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState, type Provider } from '../lib/oauth-providers.js';
|
||||||
|
|
||||||
const router: RouterType = Router();
|
const router: RouterType = Router();
|
||||||
|
|
||||||
const VALID_PROVIDERS = ['google', 'github', 'apple'];
|
const VALID_PROVIDERS: Provider[] = ['google', 'github', 'apple'];
|
||||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
// GET /auth/oauth/:provider — redirect to provider's authorization page
|
function isValidProvider(value: string): value is Provider {
|
||||||
|
return (VALID_PROVIDERS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:provider', (req, res) => {
|
router.get('/:provider', (req, res) => {
|
||||||
const { provider } = req.params;
|
const { provider } = req.params;
|
||||||
if (!VALID_PROVIDERS.includes(provider)) {
|
if (!isValidProvider(provider)) {
|
||||||
res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } });
|
res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -24,17 +27,31 @@ router.get('/:provider', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /auth/oauth/:provider/callback — handle provider callback
|
|
||||||
router.get('/:provider/callback', async (req, res) => {
|
router.get('/:provider/callback', async (req, res) => {
|
||||||
const { provider } = req.params;
|
const { provider } = req.params;
|
||||||
const { code, state, error: oauthError } = req.query as Record<string, string>;
|
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) {
|
if (oauthError) {
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`);
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state || !isValidProvider(provider)) {
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`);
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,51 +77,12 @@ router.get('/:provider/callback', async (req, res) => {
|
|||||||
console.error(`OAuth callback error (${provider}):`, err);
|
console.error(`OAuth callback error (${provider}):`, err);
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Apple sends callback as POST (form_post response mode)
|
|
||||||
router.post('/:provider/callback', async (req, res) => {
|
|
||||||
const { provider } = req.params;
|
|
||||||
const { code, state, error: oauthError } = req.body;
|
|
||||||
|
|
||||||
if (oauthError) {
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code || !state) {
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateState(state, provider)) {
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = await exchangeCodeForToken(provider, code);
|
|
||||||
const providerUser = await fetchProviderUser(provider, token);
|
|
||||||
if (!providerUser.email) {
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await findOrCreateUser(provider, providerUser);
|
|
||||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
|
||||||
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`OAuth POST callback error (${provider}):`, err);
|
|
||||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function findOrCreateUser(
|
async function findOrCreateUser(
|
||||||
provider: string,
|
provider: string,
|
||||||
providerUser: { id: string; email: string; name: string; avatarUrl: string | null },
|
providerUser: { id: string; email: string; name: string; avatarUrl: string | null },
|
||||||
) {
|
) {
|
||||||
// 1. Check existing OAuthAccount
|
|
||||||
const existingOAuth = await prisma.oAuthAccount.findUnique({
|
const existingOAuth = await prisma.oAuthAccount.findUnique({
|
||||||
where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } },
|
where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } },
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
@@ -119,7 +97,6 @@ async function findOrCreateUser(
|
|||||||
return existingOAuth.user;
|
return existingOAuth.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check existing user by email — link OAuth account
|
|
||||||
const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
await prisma.oAuthAccount.create({
|
await prisma.oAuthAccount.create({
|
||||||
@@ -134,7 +111,7 @@ async function findOrCreateUser(
|
|||||||
return existingUser;
|
return existingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create new user + OAuth account
|
try {
|
||||||
const newUser = await prisma.user.create({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: providerUser.email,
|
email: providerUser.email,
|
||||||
@@ -147,6 +124,19 @@ async function findOrCreateUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return newUser;
|
return newUser;
|
||||||
|
} catch (err: any) {
|
||||||
|
// Handle race condition: concurrent OAuth with same email
|
||||||
|
if (err?.code === 'P2002') {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: providerUser.email } });
|
||||||
|
if (user) {
|
||||||
|
await prisma.oAuthAccount.create({
|
||||||
|
data: { userId: user.id, provider, providerAccountId: providerUser.id },
|
||||||
|
}).catch(() => {}); // Ignore if OAuthAccount also raced
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,36 +1,51 @@
|
|||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } 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() {
|
export default function AuthBranding() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden items-center justify-center p-12"
|
<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))' }}>
|
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 -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="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="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">
|
<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">
|
<Logo className="w-10 h-10 text-white" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Product name */}
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
<h1 className="text-4xl font-bold tracking-tight mb-3">
|
||||||
{t('auth.productName')}
|
{t('auth.productName')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Slogan */}
|
|
||||||
<p className="text-xl text-white/90 mb-10 leading-relaxed">
|
<p className="text-xl text-white/90 mb-10 leading-relaxed">
|
||||||
{t('auth.slogan')}
|
{t('auth.slogan')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Feature highlights */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => (
|
{['auth.feature1', 'auth.feature2', 'auth.feature3'].map((key) => (
|
||||||
<div key={key} className="flex items-start gap-3">
|
<div key={key} className="flex items-start gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
|
import { API_BASE } from '../lib/api';
|
||||||
const API_BASE = '/api';
|
|
||||||
|
|
||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE = '/api';
|
export const API_BASE = '/api';
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 from '../components/AuthBranding';
|
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||||
import OAuthButtons from '../components/OAuthButtons';
|
import OAuthButtons from '../components/OAuthButtons';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -63,18 +63,7 @@ export default function Login() {
|
|||||||
}} />
|
}} />
|
||||||
|
|
||||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||||
{/* Mobile-only brand (visible when left panel is hidden) */}
|
<MobileBranding />
|
||||||
<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) */}
|
{/* Title (desktop) */}
|
||||||
<div className="hidden lg:block mb-8">
|
<div className="hidden lg:block mb-8">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 from '../components/AuthBranding';
|
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||||
import OAuthButtons from '../components/OAuthButtons';
|
import OAuthButtons from '../components/OAuthButtons';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
@@ -72,18 +72,7 @@ export default function Register() {
|
|||||||
}} />
|
}} />
|
||||||
|
|
||||||
<div className="w-full max-w-[400px] relative animate-slide-up">
|
<div className="w-full max-w-[400px] relative animate-slide-up">
|
||||||
{/* Mobile-only brand */}
|
<MobileBranding />
|
||||||
<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) */}
|
{/* Title (desktop) */}
|
||||||
<div className="hidden lg:block mb-8">
|
<div className="hidden lg:block mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user