sign-in-form.tsx304 lines · main
1'use client';
2
3import { useEffect, useState, type FormEvent } from 'react';
4import { FaDiscord, FaGithub } from 'react-icons/fa';
5import { FcGoogle } from 'react-icons/fc';
6
7export interface Providers {
8 google: boolean;
9 github: boolean;
10 discord: boolean;
11 konnos: boolean;
12}
13
14interface Props {
15 next: string;
16 apiOrigin: string;
17 disabled?: boolean;
18 providers: Providers;
19}
20
21/**
22 * Google / GitHub / Discord → Better Auth socialProviders (/sign-in/social).
23 * Konnos (Git at code.konnos.org) → genericOAuth (/sign-in/oauth2, providerId: konnos).
24 */
25type SocialKind = 'google' | 'github' | 'discord';
26type ProviderKind = SocialKind | 'konnos';
27
28function formatMmSs(totalSec: number): string {
29 const s = Math.max(0, totalSec);
30 const m = Math.floor(s / 60);
31 const r = s % 60;
32 return `${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
33}
34
35export function SignInForm({ next, apiOrigin, disabled, providers }: Props) {
36 const [email, setEmail] = useState('');
37 const [pending, setPending] = useState(false);
38 const [oauthPending, setOauthPending] = useState<ProviderKind | null>(null);
39 const [sent, setSent] = useState(false);
40 const [error, setError] = useState<string | null>(null);
41 /** Seconds left until we recommend checking spam (starts at 2:00). */
42 const [spamCountdown, setSpamCountdown] = useState(120);
43
44 useEffect(() => {
45 if (!sent) return;
46 setSpamCountdown(120);
47 const id = window.setInterval(() => {
48 setSpamCountdown((prev) => (prev <= 0 ? 0 : prev - 1));
49 }, 1000);
50 return () => window.clearInterval(id);
51 }, [sent]);
52
53 async function onSubmit(e: FormEvent<HTMLFormElement>) {
54 e.preventDefault();
55 setPending(true);
56 setError(null);
57 try {
58 const callbackURL = `${window.location.origin}${next}`;
59 const res = await fetch(`${apiOrigin}/v1/auth/sign-in/magic-link`, {
60 method: 'POST',
61 headers: { 'content-type': 'application/json' },
62 credentials: 'include',
63 body: JSON.stringify({ email, callbackURL }),
64 });
65 if (!res.ok) {
66 const body = await res.text().catch(() => '');
67 throw new Error(body || `request failed (${res.status})`);
68 }
69 setSent(true);
70 } catch (err) {
71 setError(err instanceof Error ? err.message : 'something went wrong');
72 } finally {
73 setPending(false);
74 }
75 }
76
77 async function onSocial(kind: SocialKind) {
78 setOauthPending(kind);
79 setError(null);
80 try {
81 const callbackURL = `${window.location.origin}${next}`;
82 const errorCallbackURL = `${window.location.origin}/signin?error=oauth_${kind}`;
83 const res = await fetch(`${apiOrigin}/v1/auth/sign-in/social`, {
84 method: 'POST',
85 headers: { 'content-type': 'application/json' },
86 credentials: 'include',
87 body: JSON.stringify({ provider: kind, callbackURL, errorCallbackURL }),
88 });
89 if (!res.ok) {
90 const text = await res.text().catch(() => '');
91 throw new Error(text || `request failed (${res.status})`);
92 }
93 const data = (await res.json()) as { url?: string };
94 if (!data.url) throw new Error('no redirect url returned');
95 window.location.href = data.url;
96 } catch (err) {
97 setError(err instanceof Error ? err.message : `${kind} sign-in failed`);
98 setOauthPending(null);
99 }
100 }
101
102 async function onKonnos() {
103 setOauthPending('konnos');
104 setError(null);
105 try {
106 const callbackURL = `${window.location.origin}${next}`;
107 const errorCallbackURL = `${window.location.origin}/signin?error=oauth_konnos`;
108 // genericOAuth plugin endpoint (not socialProviders)
109 const res = await fetch(`${apiOrigin}/v1/auth/sign-in/oauth2`, {
110 method: 'POST',
111 headers: { 'content-type': 'application/json' },
112 credentials: 'include',
113 body: JSON.stringify({
114 providerId: 'konnos',
115 callbackURL,
116 errorCallbackURL,
117 }),
118 });
119 if (!res.ok) {
120 const text = await res.text().catch(() => '');
121 throw new Error(text || `request failed (${res.status})`);
122 }
123 const data = (await res.json()) as { url?: string };
124 if (!data.url) throw new Error('no redirect url returned');
125 window.location.href = data.url;
126 } catch (err) {
127 setError(err instanceof Error ? err.message : 'konnos sign-in failed');
128 setOauthPending(null);
129 }
130 }
131
132 const anyPending = pending || oauthPending !== null;
133 const anyOAuth =
134 providers.google || providers.github || providers.discord || providers.konnos;
135
136 if (sent) {
137 const isOutlookFamily = /@(hotmail|outlook|live|msn)\./i.test(email);
138 const clock = formatMmSs(spamCountdown);
139 const spamHint =
140 spamCountdown > 0
141 ? `don't see it yet? wait for the timer (${clock}), then check spam / junk${
142 isOutlookFamily
143 ? ' — outlook / hotmail / live / msn often hide new senders'
144 : ''
145 }.`
146 : `don't see it? check spam / junk now${
147 isOutlookFamily
148 ? ' — outlook / hotmail / live / msn often hide new senders'
149 : ''
150 }.`;
151
152 return (
153 <div className="flex flex-col gap-4">
154 <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-5 font-mono text-sm">
155 <p className="text-[var(--color-text)]">check your inbox</p>
156 <p className="mt-2 text-xs text-[var(--color-text-muted)]">
157 we sent a one-time link to{' '}
158 <span className="text-[var(--color-text)]">{email}</span>. click it to finish
159 signing in. the link expires in 10 minutes.
160 </p>
161
162 <div className="mt-5 flex w-full justify-center">
163 <div
164 className="inline-flex flex-col items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg)] px-5 py-3"
165 aria-live="polite"
166 aria-atomic="true"
167 >
168 <span className="text-[10px] uppercase tracking-widest text-[var(--color-text-muted)]">
169 check spam after
170 </span>
171 <span className="font-mono text-2xl tabular-nums tracking-tight text-[var(--color-text)]">
172 {clock}
173 </span>
174 </div>
175 </div>
176
177 <ul className="mt-3 flex flex-col gap-1 text-xs text-[var(--color-text-subtle)]">
178 <li>· {spamHint}</li>
179 <li>
180 · prefer google, github, or konnos? go back and use those buttons instead.
181 </li>
182 </ul>
183 </div>
184 <button
185 type="button"
186 onClick={() => {
187 setSent(false);
188 setEmail('');
189 setSpamCountdown(120);
190 }}
191 className="self-start font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
192 >
193 ← use a different email
194 </button>
195 </div>
196 );
197 }
198
199 return (
200 <div className="flex flex-col gap-4" aria-busy={anyPending}>
201 {anyOAuth ? (
202 <>
203 {providers.google ? (
204 <button
205 type="button"
206 onClick={() => onSocial('google')}
207 disabled={disabled || anyPending}
208 className="inline-flex items-center justify-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-mono text-sm text-[var(--color-text)] transition hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-raised)] disabled:opacity-50"
209 >
210 <span className="inline-flex h-5 w-5 items-center justify-center">
211 <FcGoogle />
212 </span>
213 {oauthPending === 'google' ? 'redirecting...' : 'continue with google'}
214 </button>
215 ) : null}
216
217 {providers.github ? (
218 <button
219 type="button"
220 onClick={() => onSocial('github')}
221 disabled={disabled || anyPending}
222 className="inline-flex items-center justify-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-mono text-sm text-[var(--color-text)] transition hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-raised)] disabled:opacity-50"
223 >
224 <span className="inline-flex h-5 w-5 items-center justify-center">
225 <FaGithub />
226 </span>
227 {oauthPending === 'github' ? 'redirecting...' : 'continue with github'}
228 </button>
229 ) : null}
230
231 {providers.konnos ? (
232 <button
233 type="button"
234 onClick={() => void onKonnos()}
235 disabled={disabled || anyPending}
236 className="inline-flex items-center justify-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-mono text-sm text-[var(--color-text)] transition hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-raised)] disabled:opacity-50"
237 >
238 {/* Official Konnos mark (logo.svg) — auto when Konnos OAuth is on */}
239 <img
240 src="/konnos.svg"
241 alt=""
242 width={20}
243 height={20}
244 className="h-5 w-5 rounded-sm object-contain"
245 aria-hidden
246 />
247 {oauthPending === 'konnos' ? 'redirecting...' : 'continue with konnos'}
248 </button>
249 ) : null}
250
251 {providers.discord ? (
252 <button
253 type="button"
254 onClick={() => onSocial('discord')}
255 disabled={disabled || anyPending}
256 className="inline-flex items-center justify-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2.5 font-mono text-sm text-[var(--color-text)] transition hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-raised)] disabled:opacity-50"
257 >
258 <span className="inline-flex h-5 w-5 items-center justify-center text-[#5865F2]">
259 <FaDiscord />
260 </span>
261 {oauthPending === 'discord' ? 'redirecting...' : 'continue with discord'}
262 </button>
263 ) : null}
264
265 <div className="flex items-center gap-3">
266 <span className="h-px flex-1 bg-[var(--color-border-subtle)]" />
267 <span className="font-mono text-xs text-[var(--color-text-subtle)]">or</span>
268 <span className="h-px flex-1 bg-[var(--color-border-subtle)]" />
269 </div>
270 </>
271 ) : null}
272
273 <form onSubmit={onSubmit} className="flex flex-col gap-3">
274 <label className="flex flex-col gap-2">
275 <span className="font-mono text-xs text-[var(--color-text-muted)]">email</span>
276 <input
277 type="email"
278 autoComplete="email"
279 required
280 disabled={disabled || anyPending}
281 value={email}
282 onChange={(e) => setEmail(e.currentTarget.value)}
283 placeholder="you@example.com"
284 className="rounded-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"
285 />
286 </label>
287
288 <button
289 type="submit"
290 disabled={disabled || anyPending || !email}
291 className="mt-2 inline-flex items-center justify-center rounded-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"
292 >
293 {pending ? 'sending...' : 'send magic link'}
294 </button>
295 </form>
296
297 {error ? (
298 <p role="alert" className="font-mono text-xs text-red-400">
299 {error}
300 </p>
301 ) : null}
302 </div>
303 );
304}