City Portal & Magic-Link Auth
City compliance officers don't want another password. The Levy city portal at /city/{slug} uses email-based magic-link auth — enter your email, click a link, you're in. The session is HMAC-signed, lasts 24 hours, and is scoped to a single jurisdiction.
Who can log in
Only emails listed in city_contacts with portal_access = true can authenticate. Adding a city contact happens in the operator dashboard, not the portal itself — there is no self-service signup.
URL structure
| Page | URL | Purpose |
|---|---|---|
| Login | /city/{slug} | Enter email, request magic link |
| Auth callback | /api/city/{slug}/auth/callback?token=<tok> | Consume the magic link, set session cookie, redirect to dashboard |
| Dashboard | /city/{slug} (post-auth) | Compliance scoreboard + links to other views |
| Fleet map | /city/{slug}/fleet | Vehicles inside the jurisdiction's bbox |
| Trip heatmap | /city/{slug}/trips | Time-bucketed trip density |
| Parking corrals | /city/{slug}/parking-corrals | Utilization snapshots |
| Compliance report | /city/{slug}/compliance-report?period=monthly&date=2026-05 | Download CSV/PDF |
The slug matches mds_jurisdictions.slug. One slug = one jurisdiction = one bbox.
Magic-link flow
Contact enters their email
On /city/{slug}, the form posts to POST /api/city/{slug}/auth/magic-link with { email }. The route always returns HTTP 200 — we don't reveal whether the email exists in city_contacts (avoids enumeration).
Token minted and emailed
If the email matches a city_contacts row with portal_access = true for this slug, the route generates a one-time token, stores its sha256 hash + expires_at = now() + 15 minutes on the contact row, and emails the link.
Contact clicks the link
The link goes to /api/city/{slug}/auth/callback?token=<tok>. The route hashes the token, looks up the matching city_contacts row, checks expires_at, marks the token consumed, and issues a session cookie.
Session cookie set
Set-Cookie: levy_city_session=<hmac>; Path=/city/<slug>; HttpOnly; Secure; SameSite=Lax; Max-Age=86400. The cookie payload encodes city_contact_id, jurisdiction_id, issued_at. HMAC-signed with a server-side secret.
Redirect to /city/{slug}
Now showing the post-auth dashboard. Every subsequent request to /api/city/{slug}/* verifies the HMAC and bbox-clips the response.
What the contact sees
The dashboard shows:
- Compliance scoreboard — pass/fail per
permit_conditionsrow, 30-day rolling - Today's snapshot — deployed fleet count, trips, open complaints
- Active policies — the rules ingested from their own Policy feed (sanity check)
- Links — to fleet map, trip heatmap, parking corrals, monthly report
Everything below the snapshot is read-only. The portal does not let the city edit operator data — they edit their own policy feed, and we re-ingest it.
Bbox clipping
Every city-portal API route is clipped to the jurisdiction's bbox before returning data. The clipping is enforced server-side, not at the UI layer:
// Inside every /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]);
A city contact will never see a vehicle outside their boundary, even if it exists in the same subaccount.
For the trip heatmap, clipping is performed on the trip's start point — a trip that began inside the jurisdiction and ended outside still appears on the heatmap. This is what cities expect for trip-origination analytics.
Session expiry + refresh
Sessions last 24 hours. There is no refresh flow — the contact requests a new magic link when the session lapses. This is deliberate: a city analyst losing access for a few hours and going through the magic-link motion again is acceptable; long-lived city portal sessions are not.
The HMAC secret can be rotated by setting a new value in environment config and redeploying. Sessions signed with the old secret immediately fail HMAC verification and the contact gets a clean re-login prompt.
Multiple contacts per jurisdiction
A jurisdiction can have any number of city_contacts rows. Common pattern:
| Role | |
|---|---|
| Lead compliance officer | sarah@city.gov |
| Permit reviewer | pat@city.gov |
| Populus / Ride Report analyst (consultant) | analyst@populus.ai |
All three see the same data, all three receive digest emails on their own cadence. Sessions are independent — one contact logging out does not affect the others.
Revoking access
To revoke a contact's access:
- Open
/dashboard/compliance/{jurisdiction-id}-> City contacts. - Set
portal_access = falseon the row, or delete it outright. - Active session cookies on that contact will fail server-side verification at the next request — the cookie is still valid HMAC, but the contact row no longer permits portal access.
Magic links already minted to that contact are also invalidated — auth/callback rejects tokens for contacts where portal_access = false.
Localization
The portal is rendered in the contact's language preference (city_contacts.locale, defaults to en). Digest emails also use the same preference. Launch supports en and es; additional locales fall back to English.
Troubleshooting
- Contact says "didn't get the email" — check spam, then confirm the email matches
city_contacts.emailexactly (case-insensitive). Re-send by entering the email in the portal form again. - Link says "expired" — magic links live 15 minutes. The contact should request a new one.
- Link says "invalid" — the token was already consumed, the contact row was deleted, or
portal_accesswas set to false. Investigate in the operator dashboard. - Session cookie not setting — the contact is on a browser that blocks third-party cookies and
/city/{slug}is being loaded inside an iframe somewhere. Avoid iframe embeds.
What's next
- Digest Emails and Cadence — what the contact receives in their inbox.
- Permit-Condition Reports — what the dashboard scoreboard is checking.
Running a fleet on this hardware?
Levy Fleets runs scooters, e-bikes, mopeds and golf carts on the IoT hardware you already have, with integration pages for 29 vendors including Segway, Okai, Omni, Queclink, Invers and Comodule. Keyless unlock, geofencing, rider payments and rider support are included, and the Managed plan is a share of revenue with no per-vehicle software fee.