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>
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:
- 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.
- 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(orpartner_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 callres.users.x_fc_tablet_pin_set_date— refreshed on setres.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); newevent_typevalues: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_hashResUsers._verify_tablet_pin_hash(pin, stored)— same constant-time comparisonResUsers.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:
- Lookup user by
login, sudo (kiosk session is the kiosk user, not the target user). - Verify user is active + holds a shop-branch group (same check as
_check_credentials). - Resolve recipient email:
user.loginif it looks like email, elseuser.partner_id.email. If neither → return{ok: False, error: 'no_email', masked_email: None, manager_name: '<owner>'}. - Rate-limit check: count
fp.tablet.pin.resetrows for this user wherecreate_date > now - 60min. If ≥ 3 → return{ok: False, error: 'rate_limited', cooldown_minutes: <minutes until oldest of the 3 ages out>}. - Generate 4-digit code with
secrets.randbelow(10000)zero-padded. - Hash the code; write
fp.tablet.pin.resetrow withexpires_at = now + 72h. SQL constraint replaces any existing active row. - Dispatch email via
fp.notification.template._dispatch('tablet_pin_reset_requested', user, partner=user.partner_id, extra_context={'code': '1234'}). - Write
fp.tablet.session.eventrow withevent_type='pin_reset_requested', IP, sid, target uid. - 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:
- Lookup user by login, sudo.
- Find active reset row for user:
domain = [('user_id', '=', uid), ('used_at', '=', False)], latest. - No active code →
{ok: False, error: 'no_active_code'}. - Expired (
expires_at < now) →{ok: False, error: 'expired'}. - Attempt-cap (
attempt_count >= 5) → invalidate (set used_at=now),{ok: False, error: 'too_many_attempts'}. - Increment
attempt_countregardless of result (so wrong codes count against the cap). _verify_tablet_pin_hash(code, row.code_hash)→ if wrong, return{ok: False, error: 'wrong_code', attempts_left: 5 - attempt_count}.- Mark
used_at = now. - Generate
reset_token: signed JWT-like string with payload{user_id, exp: now+5min, purpose: 'tablet_pin_reset'}. Signed withir.config_parameter['database.secret']. - Audit:
fp.tablet.session.eventrow withevent_type='pin_reset_code_verified'. - 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:
- If
reset_tokenprovided ANDold_pinnot provided:- Verify token signature + expiry + purpose claim.
- Resolve
user_idfrom token. - Call
user.set_tablet_pin(new_pin)(sudo — verified user via token). - Audit:
event_type='pin_set_after_reset'. - Return
{ok: True}.
- Existing branches (
old_pincheck, 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.usersmodel (existing PIN hash fields + helpers cover everything)FpPinSetupcomponent (Preferences-form-launched setup is a separate code path)- The existing
set_pinendpoint'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:
- Pick a real user with no PIN (e.g. Bernice Boakye)
- Call
request_reset_code→ assert email sent (checkmail.mail) - Pull the code from the most recent reset row via test-only shim
- Call
verify_reset_codewith the code → assert token returned - Call
set_pinwith token + new PIN → assert hash set - Call
unlock_sessionwith new PIN → assert session returned - Clean up:
clear_tablet_pin(), delete reset rows - Print pass/fail per step
Manual QA on entech
- Open tablet at
https://enplating.com/odoo/action-fp_shopfloor_landing(or wherever the landing renders) - Tap a real no-PIN tile (e.g. Bernice Boakye) → verify "Send temporary PIN" button appears
- Tap button → verify masked email shown + email arrives in user's inbox
- Tap a tile with no email on file → verify "Contact your manager" message
- Enter temp code → set new PIN → verify auto-login lands on the workstation
- Lock + tap same user → verify normal PIN entry works with the new PIN
- Enter wrong PIN 3 times → verify "Forgot?" button appears below keypad
- Tap "Forgot?" → repeats the email flow
- Toggle dark mode → verify all new screens flip cleanly
Migration / rollback
- No data migration. Pure schema-addition + new endpoints.
- Rollback path:
git revertthe implementation commits +-u fusion_plating_shopfloor+ asset bust. New model table stays (DROP TABLE fp_tablet_pin_resetonly 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):
- Sync 8 modified + 5 new files to entech
/mnt/extra-addons/custom/fusion_plating_shopfloor/ -u fusion_plating_shopfloor(no other modules)- Asset bust:
DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'; - Restart odoo
- Run battle test via odoo-shell
- Manual browser QA at the shop-floor landing URL