docs(brainstorm): tablet PIN self-service (create + reset via email)

User goal: from the Shop Floor Terminal lock screen, a user with no
PIN (or who forgot their PIN) should be able to set / reset their
own PIN without a manager's help. Today, FpPinSetup runs only from
Preferences which requires being logged in — there's no path from
the lock screen.

Design (approved, with user-picked defaults):
- Tap tile of no-PIN user -> 'Send temporary PIN' button -> email
  4-digit code, valid 72 hours -> enter code -> choose new PIN ->
  auto-login.
- For existing-PIN users: 3 failed PIN entries -> 'Forgot? Reset
  PIN via email' button appears below keypad -> same email flow.
- Both flows merge at: enter temp code -> set new PIN.
- Email goes to res.users.login (or partner_id.email fallback).
  No-email-on-file -> 'Contact your manager: <owner>' message.
- Rate limit: 3 requests per user per rolling 60 min.
- Per-code cap: 5 wrong attempts invalidates the code.
- New model fp.tablet.pin.reset stores hashed code + expires_at
  with SQL constraint enforcing one-active-row-per-user.
- 2 new endpoints (request_reset_code, verify_reset_code) + extend
  existing /fp/tablet/set_pin to accept reset_token alternative
  to old_pin.
- Audit: 3 new event_type values on fp.tablet.session.event.
- Reuses existing PBKDF2 helpers, FpPinPad component (mode prop),
  fp.notification.template dispatch, mail.template pattern.

Per CLAUDE.md Rule 25 the mail template references ONLY core
res.users fields (object.name, object.email, object.login,
object.company_id) — ctx.code is dispatched as extra_context, not
a model field. Safe at parse-time.

Self-review fixed 2 issues:
- event_kind -> event_type (real field name on fp.tablet.session.event)
- Listed existing event_type values explicitly for context

Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 16:30:36 -04:00
parent c990110646
commit 2ae1c867b5

View File

@@ -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: <Owner Name>" │
│ [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: '<owner>'}`.
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: <minutes until oldest of the 3 ages out>}`.
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: '<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
<record id="fp_mail_template_tablet_pin_reset" model="mail.template">
<field name="name">FP: Tablet PIN Reset Code</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="subject">🔒 Your ENTECH tablet temporary PIN: {{ ctx.code }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.email or object.login }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #1d4ed8; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #1d4ed8; font-weight: 600; margin-bottom: 8px;">
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your tablet temporary PIN</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
Hi <t t-out="object.name"/>, use this 4-digit PIN to unlock the
shop-floor tablet and set a new permanent PIN.
</p>
<div style="text-align: center; margin: 32px 0; padding: 24px; background: #f3f4f6; border-radius: 8px; font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 48px; font-weight: 700; letter-spacing: 0.3em; color: #1d4ed8;">
<t t-out="ctx.code"/>
</div>
<p style="margin: 16px 0; font-size: 13px; opacity: 0.65;">
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.
</p>
</div>
</field>
</record>
```
Plus a `fp.notification.template` row pointing at the mail.template:
```xml
<record id="fp_notif_tablet_pin_reset" model="fp.notification.template">
<field name="name">Tablet PIN Reset Code</field>
<field name="trigger_event">tablet_pin_reset_requested</field>
<field name="mail_template_id" ref="fp_mail_template_tablet_pin_reset"/>
<field name="active" eval="True"/>
</record>
```
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: <Owner.name>)" 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