sign-in-form.tsx218 lines · main
1'use client';
2
3import { 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}
12
13interface Props {
14 next: string;
15 apiOrigin: string;
16 disabled?: boolean;
17 providers: Providers;
18}
19
20/**
21 * Google, GitHub + Discord are first-class in Better Auth's socialProviders
22 * config, so they all go through /v1/auth/sign-in/social.
23 */
24type ProviderKind = 'google' | 'github' | 'discord';
25
26export function SignInForm({ next, apiOrigin, disabled, providers }: Props) {
27 const [email, setEmail] = useState('');
28 const [pending, setPending] = useState(false);
29 const [oauthPending, setOauthPending] = useState<ProviderKind | null>(null);
30 const [sent, setSent] = useState(false);
31 const [error, setError] = useState<string | null>(null);
32
33 // Why we POST directly to the api origin instead of going through the
34 // Next.js `/api/...` rewrite: in production, edge proxies (Cloudflare,
35 // some CDNs) inspect rewrite-proxied request bodies and can corrupt
36 // Better Auth's callbackURL validation, producing a spurious
37 // INVALID_CALLBACK_URL 403. Talking directly to the api avoids the
38 // proxy hop entirely. CORS on the api allows the dashboard origin.
39 async function onSubmit(e: FormEvent<HTMLFormElement>) {
40 e.preventDefault();
41 setPending(true);
42 setError(null);
43 try {
44 const callbackURL = `${window.location.origin}${next}`;
45 const res = await fetch(`${apiOrigin}/v1/auth/sign-in/magic-link`, {
46 method: 'POST',
47 headers: { 'content-type': 'application/json' },
48 credentials: 'include',
49 body: JSON.stringify({ email, callbackURL }),
50 });
51 if (!res.ok) {
52 const body = await res.text().catch(() => '');
53 throw new Error(body || `request failed (${res.status})`);
54 }
55 setSent(true);
56 } catch (err) {
57 setError(err instanceof Error ? err.message : 'something went wrong');
58 } finally {
59 setPending(false);
60 }
61 }
62
63 async function onOAuth(kind: ProviderKind) {
64 setOauthPending(kind);
65 setError(null);
66 try {
67 const callbackURL = `${window.location.origin}${next}`;
68 // When Better Auth rejects the callback (state_mismatch, scope
69 // denied, etc.) we want the user to land back on /signin with
70 // a friendly error chip, not the api origin's JSON.
71 const errorCallbackURL = `${window.location.origin}/signin?error=oauth_${kind}`;
72 const body = { provider: kind, callbackURL, errorCallbackURL };
73 const res = await fetch(`${apiOrigin}/v1/auth/sign-in/social`, {
74 method: 'POST',
75 headers: { 'content-type': 'application/json' },
76 credentials: 'include',
77 body: JSON.stringify(body),
78 });
79 if (!res.ok) {
80 const text = await res.text().catch(() => '');
81 throw new Error(text || `request failed (${res.status})`);
82 }
83 const data = (await res.json()) as { url?: string };
84 if (!data.url) throw new Error('no redirect url returned');
85 window.location.href = data.url;
86 } catch (err) {
87 setError(err instanceof Error ? err.message : `${kind} sign-in failed`);
88 setOauthPending(null);
89 }
90 }
91
92 const anyPending = pending || oauthPending !== null;
93 const anyOAuth =
94 providers.google || providers.github || providers.discord;
95
96 if (sent) {
97 const isOutlookFamily = /@(hotmail|outlook|live|msn)\./i.test(email);
98 return (
99 <div className="flex flex-col gap-4">
100 <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-5 font-mono text-sm">
101 <p className="text-[var(--color-text)]">check your inbox</p>
102 <p className="mt-2 text-xs text-[var(--color-text-muted)]">
103 if there&apos;s an account on briven for{' '}
104 <span className="text-[var(--color-text)]">{email}</span>, we sent a one-time link
105 to it. click it to finish signing in. the link expires in 10 minutes.
106 </p>
107 <ul className="mt-3 flex flex-col gap-1 text-xs text-[var(--color-text-subtle)]">
108 <li>
109 · don&apos;t see it within 2 minutes? check spam / junk
110 {isOutlookFamily
111 ? ' — outlook / hotmail / live / msn spam-filter new sender domains aggressively'
112 : ''}
113 .
114 </li>
115 <li>· briven is invite-only beta; signing in with an address that has no account is silent on purpose.</li>
116 <li>· already linked google or github before? use that button above instead.</li>
117 </ul>
118 </div>
119 <button
120 type="button"
121 onClick={() => {
122 setSent(false);
123 setEmail('');
124 }}
125 className="self-start font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
126 >
127 ← use a different email
128 </button>
129 </div>
130 );
131 }
132
133 return (
134 <div className="flex flex-col gap-4" aria-busy={anyPending}>
135 {anyOAuth ? (
136 <>
137 {providers.google ? (
138 <button
139 type="button"
140 onClick={() => onOAuth('google')}
141 disabled={disabled || anyPending}
142 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"
143 >
144 <span className="inline-flex h-5 w-5 items-center justify-center">
145 <FcGoogle />
146 </span>
147 {oauthPending === 'google' ? 'redirecting...' : 'continue with google'}
148 </button>
149 ) : null}
150
151 {providers.github ? (
152 <button
153 type="button"
154 onClick={() => onOAuth('github')}
155 disabled={disabled || anyPending}
156 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"
157 >
158 <span className="inline-flex h-5 w-5 items-center justify-center">
159 <FaGithub />
160 </span>
161 {oauthPending === 'github' ? 'redirecting...' : 'continue with github'}
162 </button>
163 ) : null}
164
165 {providers.discord ? (
166 <button
167 type="button"
168 onClick={() => onOAuth('discord')}
169 disabled={disabled || anyPending}
170 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"
171 >
172 <span className="inline-flex h-5 w-5 items-center justify-center text-[#5865F2]">
173 <FaDiscord />
174 </span>
175 {oauthPending === 'discord' ? 'redirecting...' : 'continue with discord'}
176 </button>
177 ) : null}
178
179 <div className="flex items-center gap-3">
180 <span className="h-px flex-1 bg-[var(--color-border-subtle)]" />
181 <span className="font-mono text-xs text-[var(--color-text-subtle)]">or</span>
182 <span className="h-px flex-1 bg-[var(--color-border-subtle)]" />
183 </div>
184 </>
185 ) : null}
186
187 <form onSubmit={onSubmit} className="flex flex-col gap-3">
188 <label className="flex flex-col gap-2">
189 <span className="font-mono text-xs text-[var(--color-text-muted)]">email</span>
190 <input
191 type="email"
192 autoComplete="email"
193 required
194 disabled={disabled || anyPending}
195 value={email}
196 onChange={(e) => setEmail(e.currentTarget.value)}
197 placeholder="you@example.com"
198 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"
199 />
200 </label>
201
202 <button
203 type="submit"
204 disabled={disabled || anyPending || !email}
205 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"
206 >
207 {pending ? 'sending...' : 'send magic link'}
208 </button>
209 </form>
210
211 {error ? (
212 <p role="alert" className="font-mono text-xs text-red-400">
213 {error}
214 </p>
215 ) : null}
216 </div>
217 );
218}