← back to Norma
app/login/page.tsx
177 lines
'use client';
import { useState, FormEvent, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
/* ─── Login Flow ────────────────────────────────────────────────────────── */
function LoginFlow() {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams.get('returnTo');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleEmailLogin(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok && data.success) {
const target = returnTo && returnTo.startsWith('/')
? returnTo
: (data.role === 'pulse' ? '/pulse' : '/');
router.push(target);
router.refresh();
} else {
setError(data.error ?? 'Login failed. Please try again.');
}
} catch {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
}
return (
<div className="w-full" style={{ maxWidth: 380 }}>
{/* Logo */}
<div className="text-center mb-8">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}
>
<span className="text-2xl font-black" style={{ color: 'var(--color-primary)' }}>
N
</span>
</div>
<h1 className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>
Sign in
</h1>
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-muted)' }}>
Sign in or create an account
</p>
</div>
{/* Card */}
<div
className="rounded-xl p-6"
style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}
>
{/* Standard username / password login only — OAuth (Google/Apple) removed per request */}
{(
<form onSubmit={handleEmailLogin} noValidate style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{error && (
<div
className="px-3 py-2 rounded-lg text-sm"
style={{
backgroundColor: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.3)',
color: 'var(--color-error)',
}}
role="alert"
>
{error}
</div>
)}
<input
type="text"
autoComplete="username"
autoFocus
required
className="input"
placeholder="Username or email"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
style={{ fontSize: 15, padding: '10px 14px' }}
/>
<input
type="password"
autoComplete="current-password"
required
className="input"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
style={{ fontSize: 15, padding: '10px 14px' }}
/>
<button
type="submit"
disabled={loading || !username || !password}
className="btn btn-primary btn-lg w-full"
style={{ fontSize: 15 }}
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
)}
</div>
{/* Footer */}
<p className="text-center text-xs mt-5" style={{ color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
By continuing, you agree to our{' '}
<a href="/terms" style={{ color: 'var(--color-text-secondary)', textDecoration: 'underline' }}>
Terms of Service
</a>{' '}
and{' '}
<a href="/privacy" style={{ color: 'var(--color-text-secondary)', textDecoration: 'underline' }}>
Privacy Policy
</a>
</p>
</div>
);
}
/* ─── Page ──────────────────────────────────────────────────────────────── */
export default function LoginPage() {
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: 'var(--color-bg)' }}
>
<Suspense
fallback={
<div className="w-full max-w-sm text-center">
<Loader2 size={24} className="animate-spin mx-auto" style={{ color: 'var(--color-primary)' }} />
</div>
}
>
<LoginFlow />
</Suspense>
</div>
);
}