diff --git a/fusion_plating/docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md b/fusion_plating/docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md new file mode 100644 index 00000000..07279a8d --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md @@ -0,0 +1,431 @@ +# 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