All checks were successful
Deploy to Production / deploy (push) Successful in 54s
Closes the dependency on an unbuilt email sender. New EMAIL_AUTH_ENABLED
env flag (default false). When off:
- POST /v1/auth/magic-link → 503 email_auth_disabled
- POST /v1/auth/verify → 503 email_auth_disabled
- GET /v1/auth/providers → { email: false, sms, google, github }
- Login page: hides the email/phone tab toggle (only one method),
hides the email form entirely, defaults to SMS/phone tab
Flipping EMAIL_AUTH_ENABLED=true re-enables the magic-link routes and
re-shows the email form section. Schema (magic_links table) unchanged
so this is a 1-env-flip re-enable, not a re-implementation.
SECURITY: closes audit finding Za-001 (account-takeover via
cross-provider email lookup). Without a magic-link flow, an attacker
who controls a target's inbox can no longer claim an existing
OAuth-created account. The remaining provider-mixing surface (Google
↔ GitHub at same email) requires controlling the OAuth provider
account itself, which is each provider's own security boundary.
Active login methods now: Google OAuth · GitHub OAuth · SMS code
(Twilio) · admin password (seeded, single user).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
17 KiB
TypeScript
428 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { Input, Label } from '@/components/input';
|
|
import { Logo } from '@/components/logo';
|
|
import { Button } from '@/components/ui/button';
|
|
import { apiFetch, apiUrl } from '@/lib/api';
|
|
import Link from 'next/link';
|
|
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: 'That phone number does not look right. Check the country and number.',
|
|
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.',
|
|
};
|
|
|
|
// Country dial codes for the phone-login picker. Sorted by name; Switzerland
|
|
// is the default (Swiss-built product, Swiss Twilio sender number).
|
|
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
|
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
|
{ code: 'AU', name: 'Australia', dial: '+61' },
|
|
{ code: 'AT', name: 'Austria', dial: '+43' },
|
|
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
|
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
|
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
|
{ code: 'CA', name: 'Canada', dial: '+1' },
|
|
{ code: 'CL', name: 'Chile', dial: '+56' },
|
|
{ code: 'CN', name: 'China', dial: '+86' },
|
|
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
|
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
|
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
|
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
|
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
|
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
|
{ code: 'FI', name: 'Finland', dial: '+358' },
|
|
{ code: 'FR', name: 'France', dial: '+33' },
|
|
{ code: 'DE', name: 'Germany', dial: '+49' },
|
|
{ code: 'GR', name: 'Greece', dial: '+30' },
|
|
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
|
{ code: 'HU', name: 'Hungary', dial: '+36' },
|
|
{ code: 'IS', name: 'Iceland', dial: '+354' },
|
|
{ code: 'IN', name: 'India', dial: '+91' },
|
|
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
|
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
|
{ code: 'IL', name: 'Israel', dial: '+972' },
|
|
{ code: 'IT', name: 'Italy', dial: '+39' },
|
|
{ code: 'JP', name: 'Japan', dial: '+81' },
|
|
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
|
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
|
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
|
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
|
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
|
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
|
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
|
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
|
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
|
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
|
{ code: 'NO', name: 'Norway', dial: '+47' },
|
|
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
|
{ code: 'PL', name: 'Poland', dial: '+48' },
|
|
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
|
{ code: 'RO', name: 'Romania', dial: '+40' },
|
|
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
|
{ code: 'RS', name: 'Serbia', dial: '+381' },
|
|
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
|
{ code: 'SK', name: 'Slovakia', dial: '+421' },
|
|
{ code: 'SI', name: 'Slovenia', dial: '+386' },
|
|
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
|
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
|
{ code: 'ES', name: 'Spain', dial: '+34' },
|
|
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
|
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
|
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
|
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
|
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
|
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
|
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
|
{ code: 'US', name: 'United States', dial: '+1' },
|
|
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
|
];
|
|
|
|
function dialFor(code: string): string {
|
|
return COUNTRIES.find((c) => c.code === code)?.dial ?? '+41';
|
|
}
|
|
|
|
/** Combine a dial code and a locally-typed number into strict E.164. */
|
|
function toE164(dial: string, local: string): string {
|
|
const digits = local.replace(/\D/g, '').replace(/^0+/, '');
|
|
return dial + digits;
|
|
}
|
|
|
|
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 [providers, setProviders] = useState({
|
|
google: false,
|
|
github: false,
|
|
sms: false,
|
|
email: false,
|
|
});
|
|
// Default to SMS — email is off by default until an SMTP/Resend provider
|
|
// is wired. The effect below flips to 'email' if the backend says it's on.
|
|
const [method, setMethod] = useState<'email' | 'phone'>('phone');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Email magic-link
|
|
const [email, setEmail] = useState('');
|
|
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
|
|
|
// SMS one-time code
|
|
const [country, setCountry] = useState('CH');
|
|
const [phoneLocal, setPhoneLocal] = useState('');
|
|
const [sentTo, setSentTo] = useState('');
|
|
const [code, setCode] = useState('');
|
|
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
|
const [smsBusy, setSmsBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>(
|
|
'/v1/auth/providers',
|
|
)
|
|
.then((p) => {
|
|
setProviders(p);
|
|
// Pick the most-likely method up-front: email if enabled, else SMS.
|
|
if (p.email) setMethod('email');
|
|
else if (p.sms) setMethod('phone');
|
|
})
|
|
.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 sendMagicLink(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setEmailState('sending');
|
|
setError(null);
|
|
try {
|
|
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
|
|
setEmailState('sent');
|
|
} catch (err) {
|
|
setEmailState('idle');
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
|
|
}
|
|
}
|
|
|
|
async function requestSmsCode(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSmsBusy(true);
|
|
setError(null);
|
|
const full = toE164(dialFor(country), phoneLocal);
|
|
try {
|
|
await apiFetch('/v1/auth/sms/request', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ phone: full }),
|
|
});
|
|
setSentTo(full);
|
|
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: sentTo, 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]">
|
|
Passwordless — pick whichever is easiest.
|
|
</p>
|
|
|
|
{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>
|
|
)}
|
|
{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>
|
|
)}
|
|
|
|
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
|
|
one is configured, that method's form renders directly without a
|
|
useless one-tab toggle. */}
|
|
{providers.sms && providers.email && (
|
|
<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 || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
|
{method === 'email' && providers.email && emailState !== 'sent' && (
|
|
<form onSubmit={sendMagicLink} className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
required
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@company.com"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="lg"
|
|
className="w-full"
|
|
disabled={emailState === 'sending'}
|
|
>
|
|
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
|
|
{method === 'email' && providers.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="country">Country</Label>
|
|
<select
|
|
id="country"
|
|
value={country}
|
|
onChange={(e) => setCountry(e.target.value)}
|
|
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] text-[--color-fg] transition-colors duration-200 focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]"
|
|
>
|
|
{COUNTRIES.map((c) => (
|
|
<option key={c.code} value={c.code}>
|
|
{c.name} ({c.dial})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
|
Phone number
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
type="tel"
|
|
inputMode="tel"
|
|
required
|
|
autoComplete="tel-national"
|
|
value={phoneLocal}
|
|
onChange={(e) => setPhoneLocal(e.target.value)}
|
|
placeholder="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 ${sentTo}`}>
|
|
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]">
|
|
← Back to home
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GoogleIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
|
|
<path
|
|
fill="#4285F4"
|
|
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
|
|
/>
|
|
<path
|
|
fill="#34A853"
|
|
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
|
|
/>
|
|
<path
|
|
fill="#FBBC05"
|
|
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
|
|
/>
|
|
<path
|
|
fill="#EA4335"
|
|
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
|
|
/>
|
|
</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>
|
|
);
|
|
}
|