feat(tablet_lock): PIN self-service wizard (Task 6)

4 new state-machine modes on FpTabletLock, reusing the existing
FpPinPad 4-cell component:
  - request_code    : 'Send temp PIN' button screen (no-PIN tile OR
                      after 3-fail Forgot button)
  - enter_temp_code : 4-cell pad for the emailed code
  - set_new_pin     : 4-cell pad — choose new PIN
  - confirm_new_pin : 4-cell pad — confirm new PIN

Trigger paths (per D1 + D2):
  - Tap no-PIN tile -> goes straight to request_code mode
    (onTileClick dispatches via tile.has_pin)
  - Wrong PIN 3 times -> 'Forgot? Reset PIN via email' button appears
    below the pad (gated by state.failedAttempts >= 3)

Client-side failedAttempts counter (resets on tile re-select per D14).
Server-side x_fc_tablet_pin_failed_count keeps incrementing to the
existing 5-fail lockout per D13.

After Confirm New PIN succeeds, auto-login fires unlock_session with
the new PIN. If unlock_session fails for any reason, falls back to
'PIN set, tap your tile to log in.' status.

SCSS reuses $lock-* tokens from _tablet_lock_tokens.scss — light +
dark handled by the existing token system (no new tokens needed).
Hand-Off gold gradient repeated for the primary 'Send temporary PIN'
button to match the existing tablet visual language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 16:54:09 -04:00
parent 2aa4bce089
commit 8c9b645196
3 changed files with 342 additions and 5 deletions

View File

@@ -61,6 +61,21 @@ export class FpTabletLock extends Component {
// the kiosk (= locked).
kioskUid: null,
currentUid: null,
// Spec 2026-05-25 — PIN self-service wizard states
// 'pin' — default keypad (has-PIN user)
// 'request_code' — "Send temp PIN" button screen
// 'enter_temp_code' — 4-cell pad for emailed code
// 'set_new_pin' — 4-cell pad — choose new PIN
// 'confirm_new_pin' — 4-cell pad — confirm new PIN
mode: 'pin',
failedAttempts: 0, // resets on tile re-select
maskedEmail: '',
cooldownMinutes: 0,
noEmailManager: '',
pendingResetToken: null,
pendingNewPin: null,
codeAttemptsLeft: 5,
statusMessage: '',
});
onMounted(async () => {
@@ -161,6 +176,18 @@ export class FpTabletLock extends Component {
onTileClick(userId) {
this.state.selectedTileUserId = userId;
this.state.failedAttempts = 0;
// Spec D1 — if user has no PIN, jump straight to the
// "Send temporary PIN" screen. Otherwise show the keypad.
const tile = this._tileForUser(userId);
this.state.mode = (tile && tile.has_pin) ? 'pin' : 'request_code';
this.state.statusMessage = '';
this.state.pendingResetToken = null;
this.state.pendingNewPin = null;
}
_tileForUser(userId) {
return this.state.tiles.find(t => t.user_id === userId);
}
_selectedTileName() {
@@ -184,7 +211,13 @@ export class FpTabletLock extends Component {
// navigate while we're tearing down.
return { ok: true, reloading: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
// Wrong PIN — bump client-side counter for "Forgot?" gating
this.state.failedAttempts += 1;
return {
ok: false,
error: (res && res.error) || "Unlock failed",
showForgotButton: this.state.failedAttempts >= 3,
};
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
@@ -192,6 +225,11 @@ export class FpTabletLock extends Component {
onPinCancel() {
this.state.selectedTileUserId = null;
this.state.failedAttempts = 0;
this.state.mode = 'pin';
this.state.statusMessage = '';
this.state.pendingResetToken = null;
this.state.pendingNewPin = null;
}
handOff() {
@@ -238,4 +276,133 @@ export class FpTabletLock extends Component {
? "o_fp_lock_avatar is-clocked"
: "o_fp_lock_avatar";
}
// ===== Spec 2026-05-25 — PIN self-service wizard handlers =====
/** "Forgot? Reset PIN via email" button click — from PIN entry screen
* after 3 fails. */
onForgotPinClick() {
this.state.mode = 'request_code';
this.state.statusMessage = '';
}
/** "Send temporary PIN" button click — from request_code screen. */
async onSendCodeClick() {
try {
const res = await rpc("/fp/tablet/request_reset_code", {
user_id: this.state.selectedTileUserId,
});
if (res && res.ok) {
this.state.maskedEmail = res.masked_email;
this.state.mode = 'enter_temp_code';
this.state.statusMessage = '';
return;
}
// Error states drive UI
if (res && res.error === 'no_email') {
this.state.noEmailManager = res.manager_name || '';
this.state.statusMessage = `No email on file. Contact: ${res.manager_name || 'your manager'}`;
} else if (res && res.error === 'rate_limited') {
this.state.cooldownMinutes = res.wait_minutes || 60;
this.state.statusMessage = `Too many requests. Wait ${res.wait_minutes || 60} minutes.`;
} else {
this.state.statusMessage = (res && res.error) || 'Failed to send code.';
}
} catch (err) {
this.state.statusMessage = err.message || String(err);
}
}
/** "Resend" button on the enter_temp_code screen. */
async onResendCodeClick() {
try {
const res = await rpc("/fp/tablet/request_reset_code", {
user_id: this.state.selectedTileUserId,
});
if (res && res.ok) {
this.state.maskedEmail = res.masked_email;
this.state.statusMessage = 'New code sent.';
return;
}
if (res && res.error === 'rate_limited') {
this.state.statusMessage = `Wait ${res.wait_minutes || 60} min before requesting again.`;
} else {
this.state.statusMessage = (res && res.error) || 'Resend failed.';
}
} catch (err) {
this.state.statusMessage = err.message || String(err);
}
}
/** Submit handler when user enters the 4-digit temp code. */
async onTempCodeSubmit(code) {
try {
const res = await rpc("/fp/tablet/verify_reset_code", {
user_id: this.state.selectedTileUserId,
code,
});
if (res && res.ok) {
this.state.pendingResetToken = res.reset_token;
this.state.mode = 'set_new_pin';
this.state.statusMessage = '';
return { ok: true };
}
// Error UX
const errMap = {
'wrong_code': `Wrong code. ${res.attempts_left || 0} attempts left.`,
'expired': 'Code expired. Request a new one.',
'too_many_attempts': 'Too many wrong attempts. Request a new code.',
'no_active_code': 'No active code. Send yourself a new one.',
};
return {
ok: false,
error: errMap[res && res.error] || (res && res.error) || 'Verification failed.',
};
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
}
/** Submit handler when user enters a NEW PIN (first time). */
async onNewPinSubmit(pin) {
this.state.pendingNewPin = pin;
this.state.mode = 'confirm_new_pin';
this.state.statusMessage = '';
return { ok: true };
}
/** Submit handler when user confirms the NEW PIN. */
async onConfirmNewPinSubmit(pin) {
if (pin !== this.state.pendingNewPin) {
// Reset back to the first PIN entry
this.state.pendingNewPin = null;
this.state.mode = 'set_new_pin';
return { ok: false, error: "PINs don't match. Try again." };
}
try {
const res = await rpc("/fp/tablet/set_pin", {
new_pin: pin,
reset_token: this.state.pendingResetToken,
});
if (res && res.ok) {
// Auto-login (spec D17): unlock with the new PIN
const loginRes = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin,
});
if (loginRes && loginRes.ok) {
window.location.reload();
return { ok: true, reloading: true };
}
// PIN set but unlock failed — user can tap their tile + enter
// the new PIN manually
this.state.statusMessage = 'PIN set. Tap your tile and enter the new PIN to log in.';
this.onPinCancel();
return { ok: true };
}
return { ok: false, error: (res && res.error) || 'Failed to set PIN' };
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
}
}

View File

@@ -230,3 +230,101 @@
transition: none !important;
}
}
// =====================================================================
// Spec 2026-05-25 — PIN self-service wizard screens
// (request_code / enter_temp_code / set_new_pin / confirm_new_pin)
// Reuses $lock-* tokens from _tablet_lock_tokens.scss — dark mode
// auto-flips via the existing $o-webclient-color-scheme branch.
// =====================================================================
.o_fp_lock_pinwrap {
.o_fp_lock_forgot_btn {
display: block;
margin: 14px auto 0;
padding: 8px 16px;
background: transparent;
border: 1px solid $lock-tile-border-rgba;
color: $lock-muted;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: background 0.1s ease, color 0.1s ease;
&:hover {
background: $lock-tile-hover-bg-rgba;
color: $lock-text;
}
}
.o_fp_lock_wizard {
background: $lock-frame-bg-rgba;
border: 1px solid $lock-frame-border-rgba;
box-shadow: $lock-frame-shadow;
border-radius: 16px;
padding: 32px 36px;
max-width: 480px;
margin: 0 auto;
text-align: center;
font-family: inherit;
color: $lock-text;
h3 {
font-size: 20px;
font-weight: 700;
margin: 0 0 10px;
color: $lock-text;
}
}
.o_fp_lock_wizard_lede {
font-size: 14px;
color: $lock-muted;
margin: 0 0 24px;
line-height: 1.5;
strong { color: $lock-text; font-weight: 600; }
}
.o_fp_lock_primary_btn {
background: linear-gradient(135deg, #ffd966 0%, #ffc107 100%);
border: 1px solid #d39e00;
color: #5e4400;
padding: 14px 28px;
font-size: 15px;
font-weight: 700;
border-radius: 10px;
font-family: inherit;
cursor: pointer;
margin-bottom: 16px;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
transition: transform 0.1s ease, box-shadow 0.1s ease;
i { margin-right: 8px; }
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0,0,0,0.12);
}
}
.o_fp_lock_status_message {
margin: 14px 0;
padding: 10px 14px;
background: rgba(220, 38, 38, 0.10);
border: 1px solid rgba(220, 38, 38, 0.25);
color: #b91c1c;
border-radius: 8px;
font-size: 13px;
line-height: 1.4;
}
.o_fp_lock_back_btn {
margin-top: 14px;
background: transparent;
border: 0;
color: $lock-muted;
font-size: 13px;
font-family: inherit;
cursor: pointer;
padding: 6px 12px;
&:hover { color: $lock-text; }
}
}

View File

@@ -64,10 +64,82 @@
</t>
</div>
<div t-else="" class="o_fp_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
<!-- Mode: 'pin' — default keypad for users with PIN -->
<t t-if="state.mode === 'pin'">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
<button t-if="state.failedAttempts >= 3"
class="o_fp_lock_forgot_btn"
t-on-click="onForgotPinClick">
Forgot? Reset PIN via email
</button>
</t>
<!-- Mode: 'request_code' — Send Temp PIN screen -->
<t t-elif="state.mode === 'request_code'">
<div class="o_fp_lock_wizard">
<h3 t-esc="_selectedTileName()"/>
<p class="o_fp_lock_wizard_lede">
We'll email a temporary PIN to your address
on file.
</p>
<button class="o_fp_lock_primary_btn"
t-on-click="onSendCodeClick">
<i class="fa fa-envelope"/> Send temporary PIN
</button>
<div t-if="state.statusMessage"
class="o_fp_lock_status_message"
t-esc="state.statusMessage"/>
<button class="o_fp_lock_back_btn"
t-on-click="onPinCancel">
← Back to tile selection
</button>
</div>
</t>
<!-- Mode: 'enter_temp_code' — 4-cell pad for emailed code -->
<t t-elif="state.mode === 'enter_temp_code'">
<div class="o_fp_lock_wizard">
<h3 t-esc="_selectedTileName()"/>
<p class="o_fp_lock_wizard_lede">
Check your email at
<strong t-esc="state.maskedEmail"/>
for the 4-digit temporary PIN. Valid for
72 hours.
</p>
<FpPinPad onSubmit.bind="onTempCodeSubmit"
title="''"
subtitle="'Enter temporary PIN from email'"
onCancel.bind="onPinCancel"/>
<div t-if="state.statusMessage"
class="o_fp_lock_status_message"
t-esc="state.statusMessage"/>
<button class="o_fp_lock_back_btn"
t-on-click="onResendCodeClick">
Resend code
</button>
</div>
</t>
<!-- Mode: 'set_new_pin' — 4-cell pad to choose new PIN -->
<t t-elif="state.mode === 'set_new_pin'">
<FpPinPad onSubmit.bind="onNewPinSubmit"
title="_selectedTileName()"
subtitle="'Choose your new 4-digit PIN'"
onCancel.bind="onPinCancel"/>
</t>
<!-- Mode: 'confirm_new_pin' — 4-cell pad to confirm -->
<t t-elif="state.mode === 'confirm_new_pin'">
<FpPinPad onSubmit.bind="onConfirmNewPinSubmit"
title="_selectedTileName()"
subtitle="'Confirm your new PIN'"
onCancel.bind="onPinCancel"/>
</t>
</div>
</div>
</t>