Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CheckCircle2, Clock, MapPin, RefreshCw, ScanLine, Smartphone, TimerOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { QrCode } from "@/registry/ui/qr-code"
import type { QrLoginData, QrLoginScan, QrLoginSessionStatus } from "./qr-login.contract"

/**
 * The aiming sweep over a live code. Decorative only — it says "this code is
 * still being offered", the countdown carries the real information. React 19
 * hoists this and dedupes it by href.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/qr-login.json

Prompt

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 expiresAt with 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 expiresAt renders "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 (implicit aria-live="off"), while a separate sr-only role="status" line carries one sentence per phase, so a screen reader hears "scanned on iPhone 15 Pro" once instead of a number every second.

On This Page