feat(auth): email login soft-disabled until SMTP/Resend is wired
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>
This commit is contained in:
Marco Sadjadi
2026-05-25 18:51:57 +02:00
parent aa79a71357
commit b248adf5c0
3 changed files with 51 additions and 10 deletions

View File

@@ -102,8 +102,15 @@ function errCode(err: unknown): string {
}
export default function LoginPage() {
const [providers, setProviders] = useState({ google: false, github: false, sms: false });
const [method, setMethod] = useState<'email' | 'phone'>('email');
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
@@ -119,8 +126,15 @@ export default function LoginPage() {
const [smsBusy, setSmsBusy] = useState(false);
useEffect(() => {
apiFetch<{ google: boolean; github: boolean; sms: boolean }>('/v1/auth/providers')
.then(setProviders)
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.');
@@ -218,7 +232,10 @@ export default function LoginPage() {
</div>
)}
{providers.sms && (
{/* 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'}`}
>
@@ -242,8 +259,8 @@ export default function LoginPage() {
</div>
)}
<div className={providers.sms ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
{method === 'email' && emailState !== 'sent' && (
<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>
@@ -269,7 +286,7 @@ export default function LoginPage() {
</form>
)}
{method === 'email' && emailState === 'sent' && (
{method === 'email' && providers.email && emailState === 'sent' && (
<div className="panel p-4">
<p className="text-[13px]">
Magic link sent to <span className="mono">{email}</span>.