intermediate
Stadt-Portal
Magic-Link
HMAC

Stadt-Portal & Magic-Link-Auth

Das /city/{slug}-Portal, in dem sich Stadtkontakte per E-Mail-Magic-Link anmelden -- begrenzt auf die Geometrie der Zustaendigkeit, schreibgeschuetzt, mit HMAC-signiertem 24h-Session-Cookie.

Levy Fleets TeamMay 18, 202610 min read

Stadt-Portal & Magic-Link-Auth

Stadt-Compliance-Officers wollen kein weiteres Passwort. Das Levy-Stadt-Portal unter /city/{slug} nutzt E-Mail-basierte Magic-Link-Auth -- E-Mail eingeben, Link klicken, drin. Die Session ist HMAC-signiert, gilt 24 Stunden und ist auf eine einzelne Zustaendigkeit beschraenkt.

Wer sich anmelden kann

Nur E-Mails in city_contacts mit portal_access = true koennen sich authentifizieren. Stadtkontakte werden im Betreiber-Dashboard hinzugefuegt, nicht im Portal selbst -- es gibt keine Self-Service-Anmeldung.

URL-Struktur

SeiteURLZweck
Login/city/{slug}E-Mail eingeben, Magic-Link anfordern
Auth-Callback/api/city/{slug}/auth/callback?token=<tok>Magic-Link konsumieren, Session-Cookie setzen, weiterleiten
Dashboard/city/{slug} (post-auth)Compliance-Scoreboard + Links zu anderen Sichten
Flottenkarte/city/{slug}/fleetFahrzeuge in der Zustaendigkeits-Bbox
Trip-Heatmap/city/{slug}/tripsZeitlich gebuendelte Fahrtdichte
Parkstellplaetze/city/{slug}/parking-corralsAuslastungs-Snapshots
Compliance-Bericht/city/{slug}/compliance-report?period=monthly&date=2026-05CSV/PDF herunterladen

Der Slug entspricht mds_jurisdictions.slug. Ein Slug = eine Zustaendigkeit = eine Bbox.

1

Kontakt gibt seine E-Mail ein

Auf /city/{slug} postet das Formular an POST /api/city/{slug}/auth/magic-link mit { email }. Die Route gibt immer HTTP 200 zurueck -- wir verraten nicht, ob die E-Mail in city_contacts existiert (verhindert Enumeration).

2

Token erstellt und per E-Mail gesendet

Wenn die E-Mail einer city_contacts-Zeile mit portal_access = true fuer diesen Slug entspricht, erzeugt die Route einen Einmal-Token, speichert dessen sha256-Hash + expires_at = now() + 15 Minuten auf der Kontaktzeile und sendet den Link.

3

Kontakt klickt den Link

Der Link geht an /api/city/{slug}/auth/callback?token=<tok>. Die Route hashed den Token, sucht die passende city_contacts-Zeile, prueft expires_at, markiert den Token als verbraucht und stellt ein Session-Cookie aus.

4

Session-Cookie gesetzt

Set-Cookie: levy_city_session=<hmac>; Path=/city/<slug>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400. Die Cookie-Payload kodiert city_contact_id, jurisdiction_id, issued_at. HMAC-signiert mit einem serverseitigen Geheimnis.

5

Weiterleitung zu /city/{slug}

Zeigt nun das Post-Auth-Dashboard. Jede folgende Anfrage an /api/city/{slug}/* verifiziert den HMAC und schneidet die Antwort auf die Bbox zu.

Was der Kontakt sieht

Das Dashboard zeigt:

  • Compliance-Scoreboard -- Pass/Fail pro permit_conditions-Zeile, 30-Tage-Rolling
  • Heutige Momentaufnahme -- eingesetzte Flotte, Fahrten, offene Beschwerden
  • Aktive Policies -- die aus dem eigenen Policy-Feed aufgenommenen Regeln (Pruefung)
  • Links -- zur Flottenkarte, Trip-Heatmap, Parkstellplaetzen, Monatsbericht

Alles unter der Momentaufnahme ist schreibgeschuetzt. Das Portal laesst die Stadt nicht Betreiberdaten bearbeiten -- sie bearbeiten ihren eigenen Policy-Feed, und wir nehmen ihn neu auf.

Bbox-Beschneidung

Jede Stadt-Portal-API-Route wird vor der Antwort serverseitig auf die Bbox der Zustaendigkeit beschnitten, nicht auf UI-Ebene:

// In jeder /api/city/{slug}/*-Route
const session = await verifyCitySession(request);
const jurisdiction = await getJurisdiction(session.jurisdiction_id);
const bbox = jurisdiction.bbox; // [minLng, minLat, maxLng, maxLat]

const vehicles = await sb
  .from('vehicles')
  .select('id, last_lat, last_lng, status, vehicle_number')
  .gte('last_lng', bbox[0])
  .lte('last_lng', bbox[2])
  .gte('last_lat', bbox[1])
  .lte('last_lat', bbox[3]);

Ein Stadtkontakt wird nie ein Fahrzeug ausserhalb seiner Grenze sehen, selbst wenn es im selben Subaccount existiert.

Fuer die Trip-Heatmap wird die Beschneidung am Startpunkt der Fahrt durchgefuehrt -- eine Fahrt, die in der Zustaendigkeit begann und ausserhalb endete, erscheint weiterhin auf der Heatmap. Das ist, was Staedte fuer Fahrtursprungsanalysen erwarten.

Session-Ablauf + Refresh

Sessions gelten 24 Stunden. Es gibt keinen Refresh-Ablauf -- der Kontakt fordert einen neuen Magic-Link an, wenn die Session ausgelaufen ist. Das ist absichtlich: dass ein Stadtanalyst fuer ein paar Stunden den Zugang verliert und die Magic-Link-Bewegung erneut durchlaeuft, ist akzeptabel; lange aktive Stadt-Portal-Sessions sind es nicht.

Das HMAC-Geheimnis kann durch Setzen eines neuen Werts in der Env-Konfig und Redeploy rotiert werden. Mit dem alten Geheimnis signierte Sessions schlagen sofort die HMAC-Verifikation fehl, und der Kontakt erhaelt einen sauberen Re-Login-Prompt.

Mehrere Kontakte pro Zustaendigkeit

Eine Zustaendigkeit kann beliebig viele city_contacts-Zeilen haben. Gaengiges Muster:

RolleE-Mail
Leitende Compliance-Officersarah@city.gov
Genehmigungsprueferpat@city.gov
Populus-/Ride-Report-Analyst (Berater)analyst@populus.ai

Alle drei sehen dieselben Daten, alle drei erhalten Digest-E-Mails in eigener Kadenz. Sessions sind unabhaengig -- das Ausloggen eines Kontakts beeinflusst die anderen nicht.

Zugang entziehen

Um den Zugang eines Kontakts zu entziehen:

  1. /dashboard/compliance/{jurisdiction-id} -> Stadtkontakte oeffnen.
  2. portal_access = false auf der Zeile setzen oder die Zeile loeschen.
  3. Aktive Session-Cookies dieses Kontakts schlagen bei der naechsten Anfrage die serverseitige Verifikation fehl -- das Cookie ist noch HMAC-gueltig, aber die Kontaktzeile erlaubt keinen Portal-Zugang mehr.

Bereits an diesen Kontakt ausgestellte Magic-Links werden ebenfalls ungueltig -- auth/callback weist Tokens fuer Kontakte zurueck, bei denen portal_access = false ist.

Lokalisierung

Das Portal wird in der Sprachpraeferenz des Kontakts gerendert (city_contacts.locale, Standard en). Digest-E-Mails verwenden dieselbe Praeferenz. Launch unterstuetzt en und es; zusaetzliche Locales fallen auf Englisch zurueck.

Fehlerbehebung

  • Kontakt sagt "keine E-Mail erhalten" -- Spam pruefen, dann bestaetigen, dass die E-Mail city_contacts.email exakt entspricht (case-insensitive). Erneut senden, indem die E-Mail im Portal-Formular eingegeben wird.
  • Link sagt "abgelaufen" -- Magic-Links leben 15 Minuten. Der Kontakt sollte einen neuen anfordern.
  • Link sagt "ungueltig" -- der Token wurde bereits verbraucht, die Kontaktzeile wurde geloescht oder portal_access wurde auf false gesetzt. Im Betreiber-Dashboard pruefen.
  • Session-Cookie wird nicht gesetzt -- der Kontakt ist in einem Browser, der Third-Party-Cookies blockiert, und /city/{slug} wird irgendwo in einem iframe geladen. iframe-Einbettungen vermeiden.

Was kommt als naechstes?