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:
@@ -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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user