Auth Flow
Atlas Magic-Link-Flow im Detail. Bewusst NICHT signInWithOtp, sondern custom generateActionLink + zvv-mailer für einheitliches Branding und Reply-To ict@zvv.zh.ch.
5-Step Flow
Step 1User submitted Email
Login-Form → POST /login (Server-Action)
Step 2Pre-Check via Service-Role
user_profiles.email lookup. Falls noAccount → CTA "Konto beantragen"
Step 3generateActionLink (NICHT signInWithOtp)
Supabase auth.admin.generateLink → hashed_token. App baut URL selbst.
Step 4Mail via zvv-mailer
POST /api/newsletters/<slug>/send mit rawHtml + headerTitle.
Step 5User klickt Link → /auth/confirm
verifyOtp({token_hash, type}). Session-Cookie auf App-Domain.
Pre-Check Matrix (Issue #31)
Der Pre-Check verhindert, dass auth.admin.generateLink stillschweigend einen Geist-Account anlegt — was sonst zu einem inkonsistenten Auth-State führt.
| existiert | aktiv | Verhalten | |
|---|---|---|---|
| marcel.rapold@zvv.zh.ch | Link gesendet | ||
| old.user@zvv.zh.ch | Geblockt: inaktives Profil | ||
| random@example.com | Geblockt: noAccount → CTA Signup |
Server-Action Code
app/login/actions.tstsx
'use server'
import { generateActionLink } from '@/lib/auth/generate-action-link'
import { sendNewsletter } from '@/lib/notifications/mailer-client'
export async function requestMagicLink(prev, formData) {
const email = String(formData.get('email')).trim().toLowerCase()
// 1. Pre-Check (Issue #31): existiert ein aktiver User?
const { data: profile } = await db
.from('user_profiles')
.select('id, is_active')
.eq('email', email)
.maybeSingle()
if (!profile?.is_active) return { ok: false, noAccount: true, error: '…' }
// 2. Custom Action-Link (NICHT signInWithOtp!)
const link = await generateActionLink({ type: 'magiclink', email, next, appBase })
// 3. Mail über zvv-mailer (für einheitliches ZVV-Branding)
await sendNewsletter({
subject: 'Anmelde-Link für die ZVV App',
message: buildMagicLinkHtml(link.actionLink),
headerTitle: 'Anmeldung',
recipients: [{ email }],
})
return { ok: true }
}