feat(offline): PIN lock primitives + Apple-style keypad
Groundwork for offline pass-and-play (hotseat) seat/host PIN locks: - lib/pin.ts: salted SHA-256 PinLock (newSalt/hashPin/newLock/verifyPin); a social lock for a shared device (documented, not cryptography). - components/PinPad.svelte: 4-digit keypad (set/verify/change), auto-submit on the 4th digit, shake on mismatch, hardware-keyboard support. - i18n: pin.* keys (en + ru). Engine/source/UI wiring follows.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
||||
// Modes:
|
||||
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
||||
// verify — enter a PIN; auto-checks against `lock` on the 4th digit; emits
|
||||
// { kind: 'verified' } or shakes + clears on a wrong PIN.
|
||||
// change — verify the current PIN (against `lock`), then offer "set a new PIN"
|
||||
// (the set flow) or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
||||
// The pad never shows an OK button: entering the 4th digit is the action. PIN storage
|
||||
// is a social lock, not cryptography — see lib/pin.ts.
|
||||
import Modal from './Modal.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { newLock, verifyPin, type PinLock } from '../lib/pin';
|
||||
|
||||
/** PinResult is the outcome handed to onresult when the pad completes. */
|
||||
export type PinResult =
|
||||
| { kind: 'set'; lock: PinLock }
|
||||
| { kind: 'verified' }
|
||||
| { kind: 'removed' };
|
||||
|
||||
let {
|
||||
mode,
|
||||
title = '',
|
||||
lock,
|
||||
allowRemove = false,
|
||||
onclose,
|
||||
onresult,
|
||||
}: {
|
||||
mode: 'set' | 'verify' | 'change';
|
||||
title?: string;
|
||||
lock?: PinLock;
|
||||
allowRemove?: boolean;
|
||||
onclose?: () => void;
|
||||
onresult: (r: PinResult) => void;
|
||||
} = $props();
|
||||
|
||||
const LEN = 4;
|
||||
|
||||
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
||||
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
||||
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
||||
// initial value here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
||||
let buffer = $state('');
|
||||
let first = $state(''); // the first entry, awaiting confirmation
|
||||
let error = $state(false); // drives the error prompt + shake
|
||||
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
||||
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
||||
|
||||
function promptText(): string {
|
||||
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
||||
switch (phase) {
|
||||
case 'old':
|
||||
return t('pin.enterCurrent');
|
||||
case 'confirm':
|
||||
return t('pin.repeat');
|
||||
default:
|
||||
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
||||
}
|
||||
}
|
||||
|
||||
function fail(): void {
|
||||
buffer = '';
|
||||
error = true;
|
||||
shakeSeq += 1;
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
busy = true;
|
||||
try {
|
||||
if (phase === 'old') {
|
||||
if (lock && (await verifyPin(buffer, lock))) {
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'menu';
|
||||
} else fail();
|
||||
} else if (phase === 'enter' && mode === 'verify') {
|
||||
if (lock && (await verifyPin(buffer, lock))) onresult({ kind: 'verified' });
|
||||
else fail();
|
||||
} else if (phase === 'enter') {
|
||||
first = buffer;
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'confirm';
|
||||
} else if (phase === 'confirm') {
|
||||
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
||||
else {
|
||||
first = '';
|
||||
phase = 'enter';
|
||||
fail();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function press(d: string): void {
|
||||
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
||||
error = false;
|
||||
buffer += d;
|
||||
if (buffer.length === LEN) void commit();
|
||||
}
|
||||
|
||||
function back(): void {
|
||||
if (busy || phase === 'menu') return;
|
||||
buffer = buffer.slice(0, -1);
|
||||
}
|
||||
|
||||
function startNew(): void {
|
||||
error = false;
|
||||
first = '';
|
||||
buffer = '';
|
||||
phase = 'enter';
|
||||
}
|
||||
|
||||
function onkey(e: KeyboardEvent): void {
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
press(e.key);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Backspace') {
|
||||
back();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const slots = Array.from({ length: LEN }, (_, i) => i);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<Modal {title} {onclose} overlayKeyboard>
|
||||
<div class="pad">
|
||||
{#if phase === 'menu'}
|
||||
<div class="menu">
|
||||
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
||||
{#if allowRemove}
|
||||
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
||||
{t('pin.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="prompt" class:err={error}>{promptText()}</p>
|
||||
{#key shakeSeq}
|
||||
<div class="dots" class:shake={error} aria-hidden="true">
|
||||
{#each slots as i}
|
||||
<span class="dot" class:on={i < buffer.length}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
<div class="grid">
|
||||
{#each keys as k}
|
||||
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
||||
{/each}
|
||||
<span class="key spacer"></span>
|
||||
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
||||
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted, var(--text));
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.prompt.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--text);
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.32s ease;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-7px); }
|
||||
40% { transform: translateX(7px); }
|
||||
60% { transform: translateX(-5px); }
|
||||
80% { transform: translateX(5px); }
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.key {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.key:active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.key.back {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.key.back:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.key.spacer {
|
||||
border: none;
|
||||
background: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.opt {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
.opt.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user