login-form.tsx110 lines · main
1'use client';
2
3import { useState, type FormEvent } from 'react';
4
5/**
6 * Bare superadmin sign-in: email + password ONLY.
7 *
8 * Reuses the EXISTING Better Auth email+password endpoint
9 * (POST /v1/auth/sign-in/email) through the same-origin /api/* rewrite,
10 * so there is no new auth system, no CORS, and cookies are set on the
11 * same origin the page is served from. After a successful sign-in we
12 * confirm the session is an admin via /api/v1/me before entering the
13 * cockpit; a non-admin is signed straight back out.
14 *
15 * Only the two credential fields are offered here — nothing else, by design.
16 */
17export function AdminLoginForm() {
18 const [email, setEmail] = useState('');
19 const [password, setPassword] = useState('');
20 const [pending, setPending] = useState(false);
21 const [error, setError] = useState<string | null>(null);
22
23 async function onSubmit(e: FormEvent<HTMLFormElement>) {
24 e.preventDefault();
25 setPending(true);
26 setError(null);
27 try {
28 const res = await fetch('/api/v1/auth/sign-in/email', {
29 method: 'POST',
30 headers: { 'content-type': 'application/json' },
31 credentials: 'include',
32 body: JSON.stringify({ email, password }),
33 });
34 if (!res.ok) {
35 // Never reveal which field was wrong.
36 setError('invalid email or password');
37 setPending(false);
38 return;
39 }
40
41 // Confirm admin before letting them into the cockpit.
42 const meRes = await fetch('/api/v1/me', { credentials: 'include' });
43 if (!meRes.ok) {
44 setError('invalid email or password');
45 setPending(false);
46 return;
47 }
48 const me = (await meRes.json()) as { isAdmin?: boolean };
49 if (!me.isAdmin) {
50 // Signed in fine, but not authorized for the cockpit — sign back out.
51 await fetch('/api/v1/auth/sign-out', {
52 method: 'POST',
53 credentials: 'include',
54 }).catch(() => undefined);
55 setError('not authorized');
56 setPending(false);
57 return;
58 }
59
60 window.location.href = '/admin';
61 } catch {
62 setError('invalid email or password');
63 setPending(false);
64 }
65 }
66
67 return (
68 <form onSubmit={onSubmit} className="flex flex-col gap-3" aria-busy={pending}>
69 <label className="flex flex-col gap-2">
70 <span className="font-mono text-xs text-[var(--color-text-muted)]">email</span>
71 <input
72 type="email"
73 autoComplete="email"
74 required
75 disabled={pending}
76 value={email}
77 onChange={(e) => setEmail(e.currentTarget.value)}
78 className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)] disabled:opacity-50"
79 />
80 </label>
81
82 <label className="flex flex-col gap-2">
83 <span className="font-mono text-xs text-[var(--color-text-muted)]">password</span>
84 <input
85 type="password"
86 autoComplete="current-password"
87 required
88 disabled={pending}
89 value={password}
90 onChange={(e) => setPassword(e.currentTarget.value)}
91 className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-sm outline-none focus:border-[var(--color-primary)] disabled:opacity-50"
92 />
93 </label>
94
95 <button
96 type="submit"
97 disabled={pending || !email || !password}
98 className="mt-2 inline-flex items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-primary)] px-4 py-2 font-mono text-sm font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
99 >
100 {pending ? 'signing in...' : 'sign in'}
101 </button>
102
103 {error ? (
104 <p role="alert" className="font-mono text-xs text-red-400">
105 {error}
106 </p>
107 ) : null}
108 </form>
109 );
110}