Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md
gsinghpal 2ae1c867b5 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>
2026-05-25 16:30:36 -04:00

31 KiB

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

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:

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:

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

<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:

<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.019.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