QR Login
A phone-scan sign-in panel that walks waiting → scanned → approved → expired, attributes the scan so a wrong one can be rejected, and shows a derived pairing phrase instead of the code.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/qr-login.jsonPrompt
Build a React + TypeScript + Tailwind "QrLogin" panel (zod, lucide-react, a QR
encoder such as the `qrcode` package, and a Button primitive).
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
session: { code, expiresAt (ISO), status: "waiting" | "scanned" |
"approved" | "expired",
scannedBy?: { device, location?, at (ISO) } } | null;
fallbackUrl: string; message?: string }.
- Props = z.infer of that schema plus onRefresh?, onCancel?, heading,
description, qrSize (clamped 120–240), codeParam (default "code") and
locale (default "en-US", so the server and client renders agree).
forwardRef to the root <section>, spread the rest of the props on it.
- The two-token split is RFC 8628 (device authorization grant): `code` is the
device code — secret, QR-only; `fallbackUrl` is the verification_uri, which
must never carry the code.
Behavior
- Four first-class branches. loading = a skeleton with the real anatomy (code
square, phrase row, status block, actions). empty = "no sign-in code yet"
plus a "Get a code" button when onRefresh exists. error = the payload's
`message` when it carries one (falling back to one plain default sentence)
plus "Try again" when onRefresh exists. ready = the code panel. A `ready`
payload whose session is null renders the empty branch instead of an empty
frame claiming to be live.
- Never render the code as text, in an href, or in an aria-label. Instead
derive a pairing phrase from it and show that: FNV-1a 32-bit over the code's
UTF-8 bytes (offset basis 2166136261, prime 16777619, Math.imul for the
mod-2^32 multiply), then bits 31..27 → adjective, 26..22 → noun, 5..0 → a
two-digit number. Two 32-word lists keep every word equally likely; 16 bits
= 65,536 phrases. It is a comparison token, not a secret, and never a typed
credential — 16 bits collide between concurrent pending sessions and sit far
under RFC 8628 §5.1's brute-force guidance, so the fallback paragraph points
at the verification URI only. A typed path needs a server-minted
high-entropy user_code (§3.3), which this contract does not carry.
- The QR encodes RFC 8628 §3.3.1 `verification_uri_complete`: fallbackUrl with
the code added as `codeParam`, built with `new URL` and falling back to
manual concatenation for relative URIs (query before any fragment).
- Expiry is DERIVED, never trusted: a poll can return "waiting" for a code that
already lapsed, so compare `expiresAt` against a live clock and render
expired regardless of what the payload says. `approved` is terminal — an
approval that already happened cannot expire. The countdown and the phase
are computed from the same comparison so they can never disagree.
- Clock: null until the first client tick (a `requestAnimationFrame` for the
first value, then a 1s interval), so render never calls Date.now() and the
server markup matches hydration; every clock-derived label has a placeholder
("Expires in --:--", "—") for that first pass. The interval runs only while
something on screen is measured against it — the countdown, or a scan age —
and is cleared on unmount and before any rebuild. An unparseable `expiresAt`
reads "Expiry unknown", and an expired panel reads a static sentence rather
than an age that would freeze the moment the clock stops: never a figure the
data no longer supports.
- Phase rendering: waiting = live code with an aiming sweep, phrase, countdown,
fallback link and Cancel. scanned = code blurred out so a second phone cannot
lift it off the screen, phrase still shown for comparison, scan attributed
(device, an explicitly approximate location or "Location unavailable", and a
relative age), and Cancel becomes the reject ("Not my phone — reject").
approved = terminal: no countdown, no buttons, just the attributed device.
expired = code blurred, phrase and fallback dropped (both are dead), "New
code" calls onRefresh.
- `scannedBy` is optional in every phase, so no sentence may promise a device
the payload does not carry: without it the block says the server did not
identify the device, and the approved line drops its "on the device below"
clause instead of pointing at a device that is not there.
- Relative ages pick their unit AFTER rounding: 59.9s rounds to 60, and a unit
chosen from the raw magnitude prints "60 seconds ago" — promote to the next
unit at each of the 60s / 60min / 24h boundaries.
Rendering & styling
- Semantic tokens only: bg-card panel, bg-muted/30 status block, border-dashed
phrase chip, text-primary for scanned/approved, text-destructive for expired
and for the last 30 seconds of the countdown, bg-background/70 for the cover.
- The sweep is decorative: motion-reduce:hidden, and nothing depends on it.
- ARIA: the panel is a labelled section; one sr-only role="status" line lives
outside the branches and carries a sentence per branch/phase (no timestamps
in it, or it re-announces every second — and it only names an action whose
button is really rendered), while the ticking countdown uses role="timer",
whose implicit aria-live is "off". Icons are aria-hidden, the phrase is
exposed to screen readers as one sr-only sentence, and every control has a
focus-visible ring.
- cn() merges className; the fallback link is a real <a href> to fallbackUrl.
Customization levers
- Word lists: swap PHRASE_ADJECTIVES / PHRASE_NOUNS for localized words; keep
the count a power of two so the bit slices stay uniform, and mirror the exact
same lists server-side.
- Phrase shape: swap the trailing two-digit number for a third 32-word list
(bits 21..17) for an all-words phrase, or add a fourth slice for more than 16
bits — whatever you change, change it on the server in the same commit.
- Code size and density: qrSize plus the encoder's error-correction level ("M"
by default, "H" if you overlay a logo).
- Urgency threshold: the 30s destructive switch is one constant.
- Sub-blocks: heading/description accept null for a bare panel; drop the
fallback paragraph entirely if your verification URI is not meant to be
opened by hand.
- Polling: this panel is presentational — drive `session` from your poller or
SSE stream and the phase machine follows.Concepts
- Secret in the image, phrase on the screen — the code only ever reaches the QR encoder; what the human reads is a phrase built from three disjoint bit-slices of its FNV-1a hash, 16 bits in total. The phone derives the same phrase, so comparing them proves both sides hold the same code without ever printing it.
- Derived expiry beats reported status — a polled snapshot goes stale between requests, so the panel compares
expiresAtwith its own clock and flips to expired even while the payload still says "waiting". One comparison feeds both the countdown and the phase, so the two figures cannot contradict each other. - Attribution as a rejection affordance — "scanned" is not a spinner: it names the device and an explicitly approximate location, which is the only information that lets someone say "that is not my phone" and cancel before approving.
- Honest absence — a missing geo-IP lookup renders "Location unavailable" and an unparseable
expiresAtrenders "Expiry unknown"; the panel never fills a gap with a plausible-looking guess. - Announce phases, not seconds — the ticking value lives in a
role="timer"element (implicitaria-live="off"), while a separate sr-onlyrole="status"line carries one sentence per phase, so a screen reader hears "scanned on iPhone 15 Pro" once instead of a number every second.
API Changelog Diff
An endpoint-level diff between two API versions — grouped by added, removed, changed and deprecated, expandable to the field-level story, with breaking counted apart from additive and every sunset window counted in whole days.
Release Health
A per-release stability readout — adoption derived from session counts, crash-free session and user rates against the payload's own bars, the change from the previous release by semver precedence, and the top crash groups with what it would take to clear the bar.