feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches the verified primary email via /user/emails, reuses upsertOAuthLogin. SMS: phone is now a first-class login identity. - schema: users.email nullable, users.phone added, new sms_codes table. - @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create user by phone. - apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK), per-IP throttle. /v1/auth/providers now reports google/github/sms. - login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS (number -> 6-digit code with one-time-code autofill). SMS link was rejected in favour of an OTP code — carrier link-scanners consume magic-link tokens before the user taps them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,70 +10,159 @@ import { useEffect, useState } from 'react';
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
google_failed: 'Google sign-in could not be completed. Please try again.',
|
||||
google_state: 'Google sign-in expired or was interrupted. Please try again.',
|
||||
github_failed: 'GitHub sign-in could not be completed. Please try again.',
|
||||
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
|
||||
invalid_phone: 'Enter your number in international format, e.g. +41 79 123 45 67.',
|
||||
rate_limited: 'Too many requests. Wait a few minutes and try again.',
|
||||
sms_request_failed: 'Could not send the SMS. Check the number and try again.',
|
||||
invalid_or_expired_code: 'That code has expired. Request a new one.',
|
||||
invalid_code: 'Wrong code. Check the SMS and try again.',
|
||||
too_many_attempts: 'Too many wrong attempts. Request a new code.',
|
||||
sms_verify_failed: 'Could not verify the code. Try again.',
|
||||
};
|
||||
|
||||
function errCode(err: unknown): string {
|
||||
const detail = (err as { detail?: { error?: string } }).detail;
|
||||
return detail?.error ?? (err as Error).message ?? 'unknown';
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [providers, setProviders] = useState({ google: false, github: false, sms: false });
|
||||
const [method, setMethod] = useState<'email' | 'phone'>('email');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [googleEnabled, setGoogleEnabled] = useState(false);
|
||||
|
||||
// Email magic-link
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
|
||||
// SMS one-time code
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
||||
const [smsBusy, setSmsBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ google: boolean }>('/v1/auth/providers')
|
||||
.then((r) => setGoogleEnabled(r.google))
|
||||
.catch(() => setGoogleEnabled(false));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const err = params.get('error');
|
||||
if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]);
|
||||
apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers')
|
||||
.then(setProviders)
|
||||
.catch(() => undefined);
|
||||
const err = new URLSearchParams(window.location.search).get('error');
|
||||
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
||||
}, []);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
async function sendMagicLink(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setState('sending');
|
||||
setEmailState('sending');
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/v1/auth/magic-link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
setState('sent');
|
||||
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
|
||||
setEmailState('sent');
|
||||
} catch (err) {
|
||||
setState('error');
|
||||
setError((err as Error).message);
|
||||
setEmailState('idle');
|
||||
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSmsCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSmsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/v1/auth/sms/request', { method: 'POST', body: JSON.stringify({ phone }) });
|
||||
setSmsStep('code');
|
||||
} catch (err) {
|
||||
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
|
||||
} finally {
|
||||
setSmsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySmsCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSmsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/v1/auth/sms/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.');
|
||||
setSmsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasOAuth = providers.google || providers.github;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<Logo className="mb-10" />
|
||||
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Continue with Google, or get a magic link by email.
|
||||
Passwordless — pick whichever is easiest.
|
||||
</p>
|
||||
|
||||
{state !== 'sent' ? (
|
||||
<>
|
||||
{googleEnabled && (
|
||||
<>
|
||||
<a
|
||||
href={apiUrl('/v1/auth/google')}
|
||||
className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</a>
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-[--color-border]" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
or
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[--color-border]" />
|
||||
</div>
|
||||
</>
|
||||
{hasOAuth && (
|
||||
<div className="mt-7 space-y-2">
|
||||
{providers.google && (
|
||||
<a
|
||||
href={apiUrl('/v1/auth/google')}
|
||||
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||
>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</a>
|
||||
)}
|
||||
<form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
|
||||
{providers.github && (
|
||||
<a
|
||||
href={apiUrl('/v1/auth/github')}
|
||||
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||
>
|
||||
<GitHubIcon />
|
||||
Continue with GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasOAuth && (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-[--color-border]" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
or
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[--color-border]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers.sms && (
|
||||
<div
|
||||
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
||||
>
|
||||
{(['email', 'phone'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMethod(m);
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
|
||||
method === m
|
||||
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
||||
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
||||
}`}
|
||||
>
|
||||
{m === 'email' ? 'Email' : 'Phone'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={providers.sms ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
||||
{method === 'email' && emailState !== 'sent' && (
|
||||
<form onSubmit={sendMagicLink} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
@@ -91,23 +180,94 @@ export default function LoginPage() {
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={state === 'sending'}
|
||||
disabled={emailState === 'sending'}
|
||||
>
|
||||
{state === 'sending' ? 'Sending…' : 'Send magic link'}
|
||||
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
|
||||
</Button>
|
||||
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="panel mt-7 p-4">
|
||||
<p className="text-[13px]">
|
||||
Magic link sent to <span className="mono">{email}</span>.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
||||
In dev mode the link is printed to the API console output. Check the terminal.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{method === 'email' && emailState === 'sent' && (
|
||||
<div className="panel p-4">
|
||||
<p className="text-[13px]">
|
||||
Magic link sent to <span className="mono">{email}</span>.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
||||
Open it on this device to finish signing in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{method === 'phone' && smsStep === 'phone' && (
|
||||
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone">Phone number</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
required
|
||||
autoComplete="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+41 79 123 45 67"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={smsBusy}
|
||||
>
|
||||
{smsBusy ? 'Sending…' : 'Send code'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{method === 'phone' && smsStep === 'code' && (
|
||||
<form onSubmit={verifySmsCode} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code" hint={`sent to ${phone}`}>
|
||||
6-digit code
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="123456"
|
||||
className="mono tracking-[0.3em]"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={smsBusy || code.length !== 6}
|
||||
>
|
||||
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSmsStep('phone');
|
||||
setCode('');
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
← Use a different number
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
||||
<Link href="/" className="transition-colors hover:text-[--color-fg]">
|
||||
@@ -141,3 +301,11 @@ function GoogleIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user