id: device code entry is a PIN pad — one box per character

The device-approval screen showed the 8-char user_code in one wide text field.
Make it eight boxes, the shape a person expects for a login/PIN code: type
advances, backspace steps back, paste/one-time-code autofill distribute across
the boxes and strip a separator, and the whole thing normalizes to the RFC 8628
alphabet IAM mints. Same border/focus tokens as every other control, so it reads
as one control. tsc + vite build clean.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-09 10:19:26 -07:00
co-authored by Hanzo Dev
parent 9f327cd988
commit 5834ad423d
3 changed files with 178 additions and 18 deletions
+42
View File
@@ -417,6 +417,48 @@ body {
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
}
/* Segmented code entry — one box per character (CodeInput). The boxes flex to
fill the card's width so an 8-character code fits at every size; a box is
taller than it is wide (aspect-ratio) so a row of them reads as a PIN pad, not
a split text field. The resting edge and focus ring are the same tokens every
other control uses, so this stays one control visually even though it is eight
inputs. */
.hanzo-id-codeinput {
display: flex;
gap: 8px;
width: 100%;
}
.hanzo-id-codebox {
font: inherit;
font-family: var(--font-mono);
font-size: var(--text-2xl);
text-align: center;
text-transform: uppercase;
flex: 1 1 0;
min-width: 0;
aspect-ratio: 3 / 4;
min-height: 52px;
padding: 0;
background: var(--white-05);
color: var(--foreground);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
caret-color: var(--primary);
}
.hanzo-id-codebox:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
}
.hanzo-id-codebox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 400px) {
.hanzo-id-codeinput { gap: 5px; }
.hanzo-id-codebox { font-size: var(--text-xl); min-height: 46px; }
}
.hanzo-id-device-confirm {
display: flex;
flex-direction: row;
+119
View File
@@ -0,0 +1,119 @@
import { useRef, type ClipboardEvent, type KeyboardEvent } from 'react'
/**
* Segmented code input — one box per character, the shape a person expects for a
* short login/PIN code and the one the device-approval screen shows.
*
* Controlled: `value` is the WHOLE code (the device flow's own `user_code`
* string, so the parent keeps owning it) and `onChange` gets the next whole code.
* It normalizes toward the shape IAM mints (RFC 8628 §6.1: uppercased, the
* unambiguous alphabet), so a pasted `k7m4-p2qh` lands as `K7M4P2QH` across eight
* boxes with the separator dropped — IAM validates the exact alphabet.
*
* `onChange` (not `onKeyDown`) is the primary path so it also catches paste and
* the browser's `one-time-code` autofill, which fill a value without a keystroke;
* `onKeyDown` only carries the two things a value change cannot express —
* backspace out of an already-empty box, and arrow navigation.
*/
const KEEP = /[A-Z0-9]/
const norm = (s: string) =>
s.toUpperCase().split('').filter((c) => KEEP.test(c)).join('')
export function CodeInput({
value,
onChange,
length = 8,
disabled,
autoFocus,
ariaLabel = 'Login code',
}: {
value: string
onChange: (next: string) => void
length?: number
disabled?: boolean
autoFocus?: boolean
ariaLabel?: string
}) {
const refs = useRef<Array<HTMLInputElement | null>>([])
const slots = value.padEnd(length, ' ').slice(0, length).split('')
const focus = (i: number) => {
const el = refs.current[Math.max(0, Math.min(length - 1, i))]
el?.focus()
el?.select()
}
// Write chars starting at box i (one from a keystroke, many from paste/autofill)
// and advance to the box after the last one written.
const write = (i: number, incoming: string) => {
const next = value.padEnd(length, ' ').slice(0, length).split('')
let k = 0
for (; k < incoming.length && i + k < length; k++) next[i + k] = incoming[k]!
onChange(next.join('').trimEnd())
focus(Math.min(length - 1, i + Math.max(1, k) - 1) + 1)
}
const clearAt = (i: number) => {
const next = value.padEnd(length, ' ').slice(0, length).split('')
next[i] = ' '
onChange(next.join('').trimEnd())
}
const onInput = (i: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = norm(e.target.value)
if (!raw) return clearAt(i) // deletion or an invalid char typed
write(i, raw)
}
const onKey = (i: number) => (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !slots[i]!.trim()) {
// Empty box: step back and clear the one before it (a normal box's own
// backspace is a value change, handled by onInput).
e.preventDefault()
clearAt(i - 1)
focus(i - 1)
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
focus(i - 1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
focus(i + 1)
}
}
const onPaste = (i: number) => (e: ClipboardEvent<HTMLInputElement>) => {
const pasted = norm(e.clipboardData.getData('text'))
if (!pasted) return
e.preventDefault()
write(i, pasted)
}
return (
<div className="hanzo-id-codeinput" role="group" aria-label={ariaLabel}>
{slots.map((c, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el
}}
type="text"
inputMode="text"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
aria-label={`${ariaLabel} character ${i + 1} of ${length}`}
className="hanzo-id-codebox"
maxLength={1}
value={c.trim()}
disabled={disabled}
autoFocus={autoFocus && i === 0}
onFocus={(e) => e.currentTarget.select()}
onChange={onInput(i)}
onKeyDown={onKey(i)}
onPaste={onPaste(i)}
/>
))}
</div>
)
}
+17 -18
View File
@@ -2,6 +2,10 @@ import { useEffect, useState, type ReactNode } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import { CodeInput } from '../components/CodeInput'
/** IAM mints an 8-character user_code (internal/oidc/device.go userCodeLen). */
const USER_CODE_LEN = 8
/**
* RFC 8628 device-authorization approval (`/login/oauth/device`).
@@ -228,30 +232,25 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
started this sign-in yourself.
</p>
<label className="hanzo-id-field">
<div className="hanzo-id-field">
<span>Device code</span>
<input
type="text"
inputMode="text"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
autoComplete="one-time-code"
aria-label="Device code"
className="hanzo-id-input hanzo-id-device-code"
{/* One box per character — the PIN/login-code shape. A name — and a
failure — belongs to a CODE, so editing the code drops both in the
same commit and no name is ever left on screen beside a code it was
not confirmed for. */}
<CodeInput
length={USER_CODE_LEN}
value={userCode}
// A name — and a failure — belongs to a CODE. Edit the code and both are
// dropped in the same commit, so no name is ever left on screen for a
// frame beside a code it was not confirmed for.
onChange={(e) => {
setUserCode(e.target.value)
disabled={busy}
autoFocus={blank}
ariaLabel="Device code"
onChange={(next) => {
setUserCode(next)
setApp(null)
setError(null)
}}
placeholder="e.g. K7M4P2QH"
disabled={busy}
/>
</label>
</div>
{consent && named ? (
<p className="hanzo-id-info">