diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js index 450ee38b..7b4852f0 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js @@ -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) }; + } + } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss index a7079c5a..0f73eecd 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss @@ -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; } + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml index 075648e1..738e1887 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/tablet_lock.xml @@ -64,10 +64,82 @@
+ We'll email a temporary PIN to your address + on file. +
+ + + ++ Check your email at + + for the 4-digit temporary PIN. Valid for + 72 hours. +
+