Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
'use client';
|
|
|
|
import { ClipboardEvent, KeyboardEvent, useRef, useState } from 'react';
|
|
import styles from './auth.module.css';
|
|
import { TOKEN_GROUPS, TOKEN_DIGIT_COUNT, assembleToken, isComplete, fillFrom, nextEmptyIndex, emptyBoxes, onlyDigits } from './pin';
|
|
|
|
// Pre-compute the absolute box index where each group starts so the grouped
|
|
// render and the flat box array stay in sync.
|
|
const GROUP_OFFSETS = TOKEN_GROUPS.reduce<number[]>((offsets, _size, index) => {
|
|
const prev = index === 0 ? 0 : offsets[index - 1] + TOKEN_GROUPS[index - 1];
|
|
offsets.push(prev);
|
|
return offsets;
|
|
}, []);
|
|
|
|
export function TokenInput({
|
|
disabled,
|
|
onChange,
|
|
onSubmit,
|
|
}: {
|
|
disabled?: boolean;
|
|
onChange: (token: string, complete: boolean) => void;
|
|
onSubmit: () => void;
|
|
}) {
|
|
const [boxes, setBoxes] = useState<string[]>(emptyBoxes);
|
|
const refs = useRef<Array<HTMLInputElement | null>>([]);
|
|
|
|
function commit(next: string[], focusIndex?: number) {
|
|
setBoxes(next);
|
|
onChange(assembleToken(next), isComplete(next));
|
|
if (focusIndex !== undefined) {
|
|
const target = Math.max(0, Math.min(TOKEN_DIGIT_COUNT - 1, focusIndex));
|
|
requestAnimationFrame(() => refs.current[target]?.focus());
|
|
}
|
|
}
|
|
|
|
function handleChange(index: number, raw: string) {
|
|
const digits = onlyDigits(raw);
|
|
if (digits.length === 0) {
|
|
// Field cleared.
|
|
const next = boxes.slice();
|
|
next[index] = '';
|
|
commit(next);
|
|
return;
|
|
}
|
|
// A single keystroke yields one digit; a paste into a box yields several.
|
|
const next = fillFrom(boxes, index, digits);
|
|
commit(next, index + digits.length);
|
|
}
|
|
|
|
function handleKeyDown(index: number, event: KeyboardEvent<HTMLInputElement>) {
|
|
if (event.key === 'Backspace') {
|
|
event.preventDefault();
|
|
const next = boxes.slice();
|
|
if (next[index]) {
|
|
next[index] = '';
|
|
commit(next, index);
|
|
} else if (index > 0) {
|
|
next[index - 1] = '';
|
|
commit(next, index - 1);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowLeft' && index > 0) {
|
|
event.preventDefault();
|
|
refs.current[index - 1]?.focus();
|
|
}
|
|
if (event.key === 'ArrowRight' && index < TOKEN_DIGIT_COUNT - 1) {
|
|
event.preventDefault();
|
|
refs.current[index + 1]?.focus();
|
|
}
|
|
if (event.key === 'Enter' && isComplete(boxes)) {
|
|
event.preventDefault();
|
|
onSubmit();
|
|
}
|
|
}
|
|
|
|
function handlePaste(index: number, event: ClipboardEvent<HTMLInputElement>) {
|
|
event.preventDefault();
|
|
const text = event.clipboardData.getData('text');
|
|
const next = fillFrom(boxes, index, text);
|
|
commit(next, nextEmptyIndex(next, index));
|
|
}
|
|
|
|
return (
|
|
<div className={styles.pinGroups} role="group" aria-label="Setup token">
|
|
{TOKEN_GROUPS.map((size, groupIndex) => (
|
|
<span className={styles.pinGroup} key={groupIndex}>
|
|
{groupIndex > 0 ? <span className={styles.pinSep} aria-hidden="true">—</span> : null}
|
|
{Array.from({ length: size }).map((_, withinGroup) => {
|
|
const index = GROUP_OFFSETS[groupIndex] + withinGroup;
|
|
return (
|
|
<input
|
|
key={index}
|
|
ref={(el) => { refs.current[index] = el; }}
|
|
className={styles.pinBox}
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="off"
|
|
maxLength={1}
|
|
disabled={disabled}
|
|
aria-label={`Digit ${index + 1}`}
|
|
value={boxes[index]}
|
|
onChange={(event) => handleChange(index, event.target.value)}
|
|
onKeyDown={(event) => handleKeyDown(index, event)}
|
|
onPaste={(event) => handlePaste(index, event)}
|
|
/>
|
|
);
|
|
})}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|