# Tablet PIN Self-Service (Create + Reset via Email) **Date:** 2026-05-25 **Status:** Approved for implementation (brainstorming gate) **Author:** Brainstorming session (gsinghpal) **Triggering incident:** The Shop Floor Terminal at https://enplating.com/odoo/action-fp_shopfloor_landing shows every shop-branch user as "PIN required" — only Garry Singh has one set on entech (admin set it manually). The existing PIN setup flow (`FpPinSetup` OWL via `res.users.action_open_tablet_pin_setup`) requires the user to already be logged in via the Preferences form — but a user with no PIN cannot get there. They're stuck on the lock screen with no recovery path. Likewise, a user who forgot their PIN has no self-service way back in — they have to find a Manager to call `clear_tablet_pin()` for them. ## Goal Add two self-service flows accessible **directly from the tablet lock screen**, so users can manage their own PIN without manager intervention: 1. **Create flow** — for users with no PIN yet. Tapping their tile shows a "Send temporary PIN to my email" button instead of demanding a PIN they don't have. 2. **Reset flow** — for users who forgot their PIN. After 3 failed PIN attempts on the keypad, a "Forgot? Reset PIN via email" button appears. Both flows merge at the same point: server emails a temporary 4-digit code → user enters code → user sets a new permanent PIN → auto-login. ## Out of scope (deferred to follow-on work) - **SMS as a second factor.** Email only in v1. Some shop-floor users may not have personal email but DO have a phone number — SMS could be a future enhancement once Twilio/equivalent integration is in place. - **Security questions as fallback.** No "What's your mother's maiden name?" — adds question-management overhead for marginal benefit. - **Magic-link login** (click a link in the email to bypass the lock screen entirely). 4-digit temp PIN is simpler + matches existing keypad UI. - **Manager-approval reset flow** as an alternative to email. Manager can still use the existing `clear_tablet_pin()` from the user form — out-of-band reset stays available. - **Tablet-side email preview** ("we sent your code to g***@nexasystems.ca, switch device to read it"). Mention the masked email in the response but don't render an inline email preview component. - **Personal phone number as alternative recipient.** Email pulled from `res.users.login` (or `partner_id.email`) — no new field. ## Decisions reached during brainstorming | # | Decision | Rationale | |---|---|---| | D1 | **Create-flow trigger:** tile of a PIN-less user shows a "Send temporary PIN" button as the primary action instead of the PIN pad | A user with no PIN never has to encounter a useless PIN pad. Less confusion, no error states. | | D2 | **Reset-flow trigger:** after 3 failed PIN attempts in the SAME tablet-lock session, a "Forgot? Reset PIN via email" button appears below the keypad | Doesn't expose the reset path to passing eyes (the button only appears when someone has actively tried + failed). 3 fails is below the existing 5-fail server-side lockout, so the reset path is reachable before lockout. | | D3 | **Temp code format:** 4-digit numeric, same as a permanent PIN | Reuses the existing FpPinPad component without modification. Single mental model for the operator. User explicitly chose 4-digit over 6. | | D4 | **Temp code expiry:** 72 hours from generation | User explicitly chose 72h. Forgiving for shift workers, PTO, weekend gaps. Operator can request Friday 5pm and use Monday 8am without the code dying. | | D5 | **Per-code attempt cap:** 5 wrong attempts invalidates the code | Limits brute-force window in the 72h validity period. 10,000 / 5 = 2,000 codes per attacker per user before they exhaust legitimate codes. Combined with rate limit (D6), effectively un-brute-forceable via the front door. | | D6 | **Rate limit on `request_reset_code`:** max 3 requests per user per rolling 60 minutes | Prevents spam-the-email-then-spam-the-pad attacks. Fourth request shows "Wait XX minutes before requesting another code." | | D7 | **One-time use:** code invalidated on first successful verify AND replaced if user requests a new one before consuming | A user who clicks "Send code" twice in a row gets a fresh code; old one is dead. | | D8 | **No-email-on-file handling:** tile selection still works, but the "Send temporary PIN" button is REPLACED with a "Contact your manager — no email on file" message naming the company's owner | Graceful degradation. Manager still has `clear_tablet_pin()` from the user form as an out-of-band reset. | | D9 | **Email delivery:** new `mail.template` + new `fp.notification.template` trigger event `tablet_pin_reset_requested`, dispatched via existing `_dispatch()` machinery | Mirrors the cert-authority pattern shipped earlier. Admin can edit the template body in the UI (Plating → Configuration → Quality & Documents → Notification Templates) without touching code. | | D10 | **Recipient:** the user's own `login` email (Odoo standard — `res.users.login` is the email login) | Direct, no extra field, already populated for every active user. Falls back to `partner_id.email` if `login` doesn't look like an email. | | D11 | **Email subject:** `🔒 Your ENTECH tablet temporary PIN: 1234` | 4-digit code visible in the mobile-notification glance. Operator can read the PIN from their phone's lock screen without opening the email. | | D12 | **Audit:** every `request_reset_code`, `verify_reset_code`, and post-verify `set_pin_after_reset` written to the existing `fp.tablet.session.event` table with new `event_type` values | One audit table, one query for compliance. Captures IP, kiosk sid, user-agent, acting/target uid. | | D13 | **Failed-attempts counter coordination:** client-side counter (in the OWL component state) resets on page reload + shows the reset button at 3 fails. Server-side `x_fc_tablet_pin_failed_count` keeps incrementing per existing logic up to the 5-fail lockout | The reset button is for THIS session ("I just tried 3 times and got it wrong"). The server lockout is for cross-session brute-force protection. Independent concerns, both kept. | | D14 | **3-fail client counter resets** when the user successfully enters a PIN OR navigates back to the tile selection screen | Predictable: a fresh start on the tile screen = fresh counter. | | D15 | **Temp code storage:** new dedicated model `fp.tablet.pin.reset` (one active row per user, hashed code via same PBKDF2 helper) | Hashed-at-rest. SQL unique partial index enforces "one active code per user". Easy to query for expiry-cleanup cron. | | D16 | **`set_pin` endpoint accepts a `reset_token` alternative to `old_pin`** | After verifying the temp code, the server hands back a short-lived (5 min) signed token that proves the verify happened. The final "Set new PIN" call passes that token to set_pin instead of an old_pin. No state on the client; no race conditions. | | D17 | **After successful PIN set, auto-login via the same path as normal unlock** | User went through email verification + chose a PIN — they've earned the session. No "PIN set! Please tap your tile and log in." extra step. | | D18 | **Reset-flow returns the user to tile selection if they cancel mid-flow** | Cancel button on every wizard step. Half-completed flows abandon cleanly; the temp code stays valid for 72h in case they come back. | ## Architecture ``` ┌─ TABLET LOCK SCREEN (existing) ───────────────────────────────────┐ │ [Tile Amad] [Tile Andrew] [Tile Bernice] ... │ │ │ │ Tap a tile → │ │ ├─ User has PIN set → PIN-entry screen (existing flow) │ │ └─ User has no PIN → "Send temp PIN" screen (NEW) │ └────────────────────────────────────────────────────────────────────┘ ┌─ PIN-ENTRY SCREEN (existing, EXTENDED) ───────────────────────────┐ │ [Tile + name + colored avatar] │ │ [4-cell PIN pad] │ │ │ │ Wrong PIN entered: │ │ ├─ fails < 3 in this session → red error, keypad clears │ │ └─ fails ≥ 3 in this session → "Forgot? Reset PIN via email" │ │ button appears below keypad │ │ │ │ Tap "Forgot?" → joins the temp-code email flow (below) │ └────────────────────────────────────────────────────────────────────┘ ┌─ SEND TEMP CODE SCREEN (NEW) ─────────────────────────────────────┐ │ [Tile + name] │ │ │ │ "We'll email a temporary PIN to your address on file." │ │ Email shown masked: g***@nexasystems.ca │ │ │ │ [Send temporary PIN] ← primary button │ │ [Back to tile selection] │ │ │ │ No email on file edge case: │ │ "No email on file. Contact your manager: " │ │ [Back] │ │ │ │ Rate-limited edge case: │ │ "Too many requests. Try again in 47 minutes." │ │ [Back] │ └────────────────────────────────────────────────────────────────────┘ ┌─ ENTER TEMP CODE SCREEN (NEW) ────────────────────────────────────┐ │ [Tile + name] │ │ │ │ "Check your email for the temporary PIN." │ │ "Code expires in 72 hours." │ │ │ │ [4-cell pad — same component as regular PIN entry] │ │ │ │ [Resend code] (subject to rate limit) │ │ [Cancel — back to tile selection] │ └────────────────────────────────────────────────────────────────────┘ ┌─ SET NEW PIN SCREEN (REUSE FpPinSetup) ───────────────────────────┐ │ [Tile + name] │ │ │ │ Stage 1: "Choose your new PIN" [4-cell pad] │ │ Stage 2: "Confirm your PIN" [4-cell pad] │ │ │ │ On match → server sets hash → auto-login (same path as │ │ /fp/tablet/unlock_session) → redirect to landing. │ └────────────────────────────────────────────────────────────────────┘ ``` ## Schema ### New model: `fp.tablet.pin.reset` ```python class FpTabletPinReset(models.Model): _name = 'fp.tablet.pin.reset' _description = 'Tablet PIN Email-Reset Code' _order = 'create_date desc' user_id = fields.Many2one( 'res.users', required=True, ondelete='cascade', index=True, ) code_hash = fields.Char( required=True, groups='fusion_plating.group_fusion_plating_manager', help='PBKDF2-SHA256 hash of the 4-digit temp code. Never plaintext.', ) expires_at = fields.Datetime(required=True) used_at = fields.Datetime( help='Set when the code is successfully verified. After this, ' 'the row is considered consumed; a new code must be requested.', ) attempt_count = fields.Integer( default=0, help='Per-code wrong-guess counter. 5 wrong attempts invalidate ' 'the code regardless of expires_at (D5).', ) requester_ip = fields.Char(help='IP that requested the code (audit).') _sql_constraints = [ # At most ONE active (used_at IS NULL) row per user. Forces the # "request new = invalidate old" behavior (D7). ('one_active_per_user', "EXCLUDE (user_id WITH =) WHERE (used_at IS NULL)", 'A user may have at most one outstanding tablet PIN reset code.'), ] ``` ### Existing fields used (no changes) - `res.users.x_fc_tablet_pin_hash` — written by the post-verify set-new-PIN call - `res.users.x_fc_tablet_pin_set_date` — refreshed on set - `res.users.x_fc_tablet_pin_failed_count` — server-side counter (separate from client-side 3-fail counter per D13) - `res.users.x_fc_tablet_locked_until` — existing 5-fail lockout (untouched) - `fp.tablet.session.event` — audit log (existing table); new `event_type` values: `pin_reset_requested`, `pin_reset_code_verified`, `pin_set_after_reset` ### Existing helpers reused - `ResUsers._hash_tablet_pin(pin, salt=None)` — same algorithm for code_hash - `ResUsers._verify_tablet_pin_hash(pin, stored)` — same constant-time comparison - `ResUsers.set_tablet_pin(pin)` — writes new hash + clears lockout (called after verify) ## Endpoints ### `POST /fp/tablet/request_reset_code` **Request:** `{login: str}` **Response:** `{ok: bool, masked_email: str | None, error: str | None}` **Steps:** 1. Lookup user by `login`, sudo (kiosk session is the kiosk user, not the target user). 2. Verify user is active + holds a shop-branch group (same check as `_check_credentials`). 3. Resolve recipient email: `user.login` if it looks like email, else `user.partner_id.email`. If neither → return `{ok: False, error: 'no_email', masked_email: None, manager_name: ''}`. 4. Rate-limit check: count `fp.tablet.pin.reset` rows for this user where `create_date > now - 60min`. If ≥ 3 → return `{ok: False, error: 'rate_limited', cooldown_minutes: }`. 5. Generate 4-digit code with `secrets.randbelow(10000)` zero-padded. 6. Hash the code; write `fp.tablet.pin.reset` row with `expires_at = now + 72h`. SQL constraint replaces any existing active row. 7. Dispatch email via `fp.notification.template._dispatch('tablet_pin_reset_requested', user, partner=user.partner_id, extra_context={'code': '1234'})`. 8. Write `fp.tablet.session.event` row with `event_type='pin_reset_requested'`, IP, sid, target uid. 9. Return `{ok: True, masked_email: 'g***@nexasystems.ca'}`. ### `POST /fp/tablet/verify_reset_code` **Request:** `{login: str, code: str}` **Response:** `{ok: bool, reset_token: str | None, error: str | None}` **Steps:** 1. Lookup user by login, sudo. 2. Find active reset row for user: `domain = [('user_id', '=', uid), ('used_at', '=', False)]`, latest. 3. No active code → `{ok: False, error: 'no_active_code'}`. 4. Expired (`expires_at < now`) → `{ok: False, error: 'expired'}`. 5. Attempt-cap (`attempt_count >= 5`) → invalidate (set used_at=now), `{ok: False, error: 'too_many_attempts'}`. 6. Increment `attempt_count` regardless of result (so wrong codes count against the cap). 7. `_verify_tablet_pin_hash(code, row.code_hash)` → if wrong, return `{ok: False, error: 'wrong_code', attempts_left: 5 - attempt_count}`. 8. Mark `used_at = now`. 9. Generate `reset_token`: signed JWT-like string with payload `{user_id, exp: now+5min, purpose: 'tablet_pin_reset'}`. Signed with `ir.config_parameter['database.secret']`. 10. Audit: `fp.tablet.session.event` row with `event_type='pin_reset_code_verified'`. 11. Return `{ok: True, reset_token: ''}`. ### `POST /fp/tablet/set_pin` (EXTEND existing) Currently accepts `{new_pin, old_pin?}`. Extend to accept `reset_token` as a third alternative: **Request (extended):** `{new_pin: str, old_pin: str?, reset_token: str?}` **New branch:** 1. If `reset_token` provided AND `old_pin` not provided: - Verify token signature + expiry + purpose claim. - Resolve `user_id` from token. - Call `user.set_tablet_pin(new_pin)` (sudo — verified user via token). - Audit: `event_type='pin_set_after_reset'`. - Return `{ok: True}`. 2. Existing branches (`old_pin` check, no-pin-yet-and-no-token-and-no-old-pin reject) untouched. ### Existing endpoint — `/fp/tablet/unlock_session` After `set_pin` succeeds in the reset flow, the client immediately calls `/fp/tablet/unlock_session` with `{login, pin: new_pin}` to mint the actual Odoo session. **No endpoint change.** Auto-login is a client-side chain. ## Frontend — OWL component changes ### `FpTabletLock` (existing) state machine Add four new states to the existing tile / pin-entry state machine: | State | Triggered by | Renders | |---|---|---| | `request_code` | Tap tile of a no-PIN user, OR tap "Forgot?" button at 3 fails | Send-temp-code screen | | `enter_temp_code` | After successful `request_reset_code` | 4-cell pad for the temp code | | `set_new_pin` | After successful `verify_reset_code` | 4-cell pad — "Choose your new PIN" | | `confirm_new_pin` | After first new PIN entered | 4-cell pad — "Confirm your PIN" | Three new state fields: ```javascript this.state = useState({ // ... existing failedAttempts: 0, // resets on successful PIN OR back-to-tiles pendingResetToken: null, // from verify_reset_code pendingNewPin: null, // from set_new_pin step cooldownMinutes: 0, // from rate-limit error maskedEmail: '', }); ``` Two new event handlers: ```javascript async onForgotPinClick() { // Navigate to request_code state with current selected user this.state.mode = 'request_code'; } async onPinFail() { this.state.failedAttempts += 1; if (this.state.failedAttempts >= 3) { this.state.showForgotButton = true; } // ... existing fail handling } ``` ### `FpPinPad` (existing) — minor mode prop Add a `mode` prop (`'pin' | 'temp_code' | 'new_pin' | 'confirm_pin'`) that drives: - Label above the pad ("Enter your PIN" / "Temporary PIN from email" / "Choose your new PIN" / "Confirm new PIN") - Different submit handler the parent injects via callback No structural change — same 4-cell pad, same digit buttons. ## Email template **File:** `data/fp_tablet_pin_reset_template.xml` ```xml FP: Tablet PIN Reset Code 🔒 Your ENTECH tablet temporary PIN: {{ ctx.code }} {{ (object.company_id.email or user.email) }} {{ object.email or object.login }}
Electroless Nickel Technologies Inc. (ENTECH)

Your tablet temporary PIN

Hi , use this 4-digit PIN to unlock the shop-floor tablet and set a new permanent PIN.

This code expires in 72 hours. If you didn't request it, ignore this email — no action needed. The previous PIN (if any) stays valid until you successfully complete the reset on the tablet.

``` Plus a `fp.notification.template` row pointing at the mail.template: ```xml Tablet PIN Reset Code tablet_pin_reset_requested ``` Per CLAUDE.md Rule 25, the mail template references ONLY core `res.users` fields (`object.name`, `object.email`, `object.login`, `object.company_id`). The `ctx.code` is the dispatched extra_context, passed by the controller — not a model field. Safe at parse-time. ## File inventory ### Create | Path | Purpose | |---|---| | `fusion_plating_shopfloor/models/fp_tablet_pin_reset.py` | The new model + helpers (generate, verify, cleanup-expired cron entrypoint) | | `fusion_plating_shopfloor/security/ir.model.access.csv` (extend) | ACL rows for `fp.tablet.pin.reset` — manager-only read, no user read | | `fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml` | mail.template + fp.notification.template + cron for cleanup | | `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` | TransactionCase covering: rate-limit, expiry, wrong-code attempt cap, one-active-per-user constraint, set_pin via reset_token | | `fusion_plating_shopfloor/scripts/bt_pin_reset.py` | Entech smoke — full lifecycle via odoo-shell | ### Modify | Path | Change | |---|---| | `fusion_plating_shopfloor/controllers/tablet_controller.py` | Add `request_reset_code` + `verify_reset_code` endpoints; extend `set_pin` to accept `reset_token` | | `fusion_plating_shopfloor/static/src/js/components/tablet_lock.js` | New states + handlers (`onForgotPinClick`, `onPinFail` counter, `onSendCodeClick`, `onCodeSubmit`, `onNewPinSubmit`, `onConfirmNewPinSubmit`) | | `fusion_plating_shopfloor/static/src/js/components/pin_pad.js` | New `mode` prop drives label text | | `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` | New screens (request_code, enter_temp_code, set_new_pin, confirm_new_pin) | | `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` | Styles for new screens (existing tokens reused) | | `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | Add 3 new selection values to `event_type` (existing values: `unlock`, `failed_unlock`, `manual_lock`, `idle_lock`, `ceiling_lock`, `force_lock`, `admin_reset`). New values: `pin_reset_requested`, `pin_reset_code_verified`, `pin_set_after_reset`. | | `fusion_plating_shopfloor/__init__.py` | Register the new model file | | `fusion_plating_shopfloor/__manifest__.py` | Version bump + new data files | ### Untouched - `res.users` model (existing PIN hash fields + helpers cover everything) - `FpPinSetup` component (Preferences-form-launched setup is a separate code path) - The existing `set_pin` endpoint's old-PIN-verify branch (preserved) - The existing 5-fail server lockout ## Edge cases + defensive design | Case | Behavior | |---|---| | User taps Send-code, then taps it again before email arrives | Second call invalidates the first row (SQL constraint) + sends a new email. Old code in inbox no longer works. Counts against rate limit. | | User enters wrong temp code 5 times | Code invalidated. Screen shows "Code expired due to too many wrong attempts. Request a new one." Counts toward rate limit. | | User requests 3 codes in 30 min, then hits the limit | 4th request returns `{ok: False, cooldown_minutes: 30}`. Screen shows "Wait 30 min before requesting another code." Existing active code (if any) stays valid for use within its 72h window. | | User on PTO requests code Friday, comes back Monday | 72h covers Friday-noon to Monday-noon. Tuesday return = expired, request new one. | | User has no email anywhere (`login` not email-shaped + no `partner_id.email`) | Tile shows "Contact your manager — no email on file. (Manager: )" pulled from `res.company.x_fc_owner_user_id`. Manager uses existing user-form `clear_tablet_pin()` for the out-of-band reset. | | Tablet network blip mid-set-PIN | `reset_token` has 5-min expiry, so the user can retry within that window without re-doing email. After 5 min, they need a new code (existing 72h code still valid + still has remaining attempts). | | User completes reset on tablet, then their lockout (`x_fc_tablet_locked_until`) was active | `set_tablet_pin()` already clears `x_fc_tablet_locked_until` AND `x_fc_tablet_pin_failed_count`. The reset path inherits that — successful reset unlocks the user too. | | Manager runs `clear_tablet_pin()` while user has an outstanding code | Cleanup cron will eventually remove expired codes; no immediate conflict. Manager's clear doesn't invalidate the email code, so user could still complete the reset via their email. Acceptable. | | Attacker steals tablet, taps a tile, gets to send-code screen, requests code | Code goes to the LEGITIMATE user's email — attacker doesn't have it. They could try to brute force (5 attempts before invalidation, 10k combinations). After 5 wrong → code dead + rate-limit consumed. Effectively unbreakable via the front door. | | User opens email on their phone, sees "g***@nexasystems.ca" doesn't match their actual address | Means the masked-email display has a bug OR the wrong user was selected. They can cancel + start over. Adds visible confirmation that the right account is being reset. | | Two operators try to reset the same user (admin error) | SQL unique-active constraint allows only one row; second `request` call replaces the first. Both see the masked email; whoever has access to the inbox wins. | | Reset token leaked via browser history | Token is short-lived (5 min), single-use (consumed by `set_pin`), and signed with the database secret. Even if intercepted, can only set ONE new PIN within 5 min, and the user notices their PIN change. | ## Cleanup cron Daily `ir.cron` (`_cron_purge_expired_pin_resets`) deletes rows where `expires_at < now - 7d` OR `used_at < now - 7d`. Keeps the table tidy without losing audit-window data (the audit trail is in `fp.tablet.session.event`, not in the reset rows themselves). ## Testing strategy ### Unit tests — `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` (NEW) | Test | Asserts | |---|---| | `test_request_creates_active_row` | After `request_reset_code`, exactly one row with `used_at=False`, `expires_at` 72h out | | `test_request_replaces_prior_active` | After two `request` calls, exactly one active row (newer replaces older) | | `test_rate_limit_kicks_in_at_4th_request` | 3 requests succeed, 4th returns `{ok: False, error: 'rate_limited'}` | | `test_verify_with_correct_code_returns_token` | `verify_reset_code` with the correct code returns `{ok: True, reset_token}`; row's `used_at` now set | | `test_verify_wrong_code_increments_attempt_count` | Wrong code → `attempt_count` goes from 0 → 1; returns `attempts_left: 4` | | `test_5_wrong_attempts_invalidates_code` | Five wrong codes → `used_at` set even though never successfully verified | | `test_expired_code_rejects_even_if_correct` | Backdate `expires_at` to past; verify with correct code returns `{ok: False, error: 'expired'}` | | `test_set_pin_with_valid_reset_token` | After verify, calling `set_pin({new_pin, reset_token})` writes the new hash | | `test_set_pin_with_expired_token_rejects` | Token > 5min old → set_pin returns `{ok: False, error: 'token_expired'}` | | `test_set_pin_with_token_for_wrong_user_rejects` | Token signed for user A used for user B → rejected | | `test_set_pin_clears_lockout` | User with `x_fc_tablet_locked_until` set → after reset, locked_until is null | | `test_no_email_user_returns_specific_error` | User without email → request returns `{ok: False, error: 'no_email'}` | | `test_user_without_shop_branch_role_rejects` | Non-shop user → request rejected (matches `_check_credentials` security model) | | `test_audit_event_written_on_request` | After request, one `fp.tablet.session.event` row with the right event_type | ### Entech smoke script — `fusion_plating_shopfloor/scripts/bt_pin_reset.py` End-to-end via odoo-shell: 1. Pick a real user with no PIN (e.g. Bernice Boakye) 2. Call `request_reset_code` → assert email sent (check `mail.mail`) 3. Pull the code from the most recent reset row via test-only shim 4. Call `verify_reset_code` with the code → assert token returned 5. Call `set_pin` with token + new PIN → assert hash set 6. Call `unlock_session` with new PIN → assert session returned 7. Clean up: `clear_tablet_pin()`, delete reset rows 8. Print pass/fail per step ### Manual QA on entech 1. Open tablet at `https://enplating.com/odoo/action-fp_shopfloor_landing` (or wherever the landing renders) 2. Tap a real no-PIN tile (e.g. Bernice Boakye) → verify "Send temporary PIN" button appears 3. Tap button → verify masked email shown + email arrives in user's inbox 4. Tap a tile with no email on file → verify "Contact your manager" message 5. Enter temp code → set new PIN → verify auto-login lands on the workstation 6. Lock + tap same user → verify normal PIN entry works with the new PIN 7. Enter wrong PIN 3 times → verify "Forgot?" button appears below keypad 8. Tap "Forgot?" → repeats the email flow 9. Toggle dark mode → verify all new screens flip cleanly ## Migration / rollback - **No data migration.** Pure schema-addition + new endpoints. - **Rollback path:** `git revert` the implementation commits + `-u fusion_plating_shopfloor` + asset bust. New model table stays (`DROP TABLE fp_tablet_pin_reset` only needed if you uninstall the module entirely). No production data loss. ## Status & deployment notes Target version bump: `fusion_plating_shopfloor 19.0.34.2.0` → **19.0.35.0.0**. Deploy steps (mirrors prior session work): 1. Sync 8 modified + 5 new files to entech `/mnt/extra-addons/custom/fusion_plating_shopfloor/` 2. `-u fusion_plating_shopfloor` (no other modules) 3. Asset bust: `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` 4. Restart odoo 5. Run battle test via odoo-shell 6. Manual browser QA at the shop-floor landing URL