mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user