diff --git a/fusion_plating/docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md new file mode 100644 index 00000000..fea612bd --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md @@ -0,0 +1,1872 @@ +# Tablet PIN Self-Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** From the Shop Floor Terminal lock screen, let users self-serve PIN creation (no PIN yet) AND PIN reset (forgot PIN) via a temporary 4-digit code emailed to their address on file. Eliminates the manager-bottleneck for `clear_tablet_pin()` and the catch-22 where a no-PIN user can't reach the existing Preferences-based setup flow. + +**Architecture:** New `fp.tablet.pin.reset` model stores hashed 4-digit codes (PBKDF2-SHA256, 72h expiry, one-active-per-user SQL constraint, 5 wrong-attempt cap). Two new endpoints (`request_reset_code`, `verify_reset_code`) + extension of existing `/fp/tablet/set_pin` to accept a signed short-lived `reset_token` as an alternative to `old_pin`. The frontend extends the existing `FpTabletLock` OWL component with four new wizard states (request_code → enter_temp_code → set_new_pin → confirm_new_pin) reusing the existing `FpPinPad` 4-cell component. Email goes via existing `fp.notification.template` dispatcher. Audit via existing `fp.tablet.session.event` with three new `event_type` values. + +**Spec:** [docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md](../specs/2026-05-25-tablet-pin-self-service-design.md) + +**Tech Stack:** Odoo 19, Python (api/orm), PostgreSQL (with EXCLUDE constraint for one-active-row), QWeb XML, OWL components, SCSS, HMAC-SHA256 for the reset token. + +--- + +## File Inventory (what each task touches) + +| Path | Action | Responsibility | +|---|---|---| +| `fusion_plating_shopfloor/models/fp_tablet_pin_reset.py` | **CREATE** | New model + `_hash_code` / `_verify_code` / `_sign_reset_token` / `_verify_reset_token` helpers + `_cron_purge_expired` | +| `fusion_plating_shopfloor/__init__.py` | Modify | Register the new model | +| `fusion_plating_shopfloor/security/ir.model.access.csv` | Modify | One ACL row: manager read-only on `fp.tablet.pin.reset` | +| `fusion_plating_shopfloor/models/fp_tablet_session_event.py` | Modify | Append 3 new `event_type` selection values | +| `fusion_plating_shopfloor/controllers/tablet_controller.py` | Modify | Add `request_reset_code` + `verify_reset_code` routes; extend `set_pin` to accept `reset_token` | +| `fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml` | **CREATE** | `mail.template` + `fp.notification.template` + `ir.cron` (cleanup) | +| `fusion_plating_shopfloor/static/src/js/tablet_lock.js` | Modify | Add wizard state machine (`mode` field) + 4 new handlers | +| `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` | Modify | Add 4 new screens (request_code / enter_temp_code / set_new_pin / confirm_new_pin) | +| `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` | Modify | Styles for new screens (reuse existing tokens) | +| `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` | **CREATE** | TransactionCase covering 14 scenarios from the spec | +| `fusion_plating_shopfloor/tests/__init__.py` | Modify | Register the new test module | +| `fusion_plating_shopfloor/scripts/bt_pin_reset.py` | **CREATE** | Entech smoke (full lifecycle via odoo-shell) | +| `fusion_plating_shopfloor/__manifest__.py` | Modify | Version bump `19.0.34.2.0` → `19.0.35.0.0`; add new data file | + +--- + +## Phase 1 — Backend foundation + +### Task 1: New model `fp.tablet.pin.reset` + ACL + event_type extension + model tests + +**Files:** +- Create: `fusion_plating_shopfloor/models/fp_tablet_pin_reset.py` +- Modify: `fusion_plating_shopfloor/__init__.py` +- Modify: `fusion_plating_shopfloor/security/ir.model.access.csv` +- Modify: `fusion_plating_shopfloor/models/fp_tablet_session_event.py` +- Create: `fusion_plating_shopfloor/tests/test_pin_reset_flow.py` +- Modify: `fusion_plating_shopfloor/tests/__init__.py` + +- [ ] **Step 1: Create the model file** + +Create [`fusion_plating_shopfloor/models/fp_tablet_pin_reset.py`](../../fusion_plating_shopfloor/models/fp_tablet_pin_reset.py): + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Tablet PIN email-reset codes. + +Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md + +Stores hashed 4-digit codes emailed to users for self-service PIN +creation OR reset. One active row per user (SQL constraint). 72-hour +expiry. 5-wrong-attempts-per-code cap. Cleaned up daily by cron. +""" +import base64 +import hashlib +import hmac +import json +import logging +import secrets +import time +from datetime import timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +# Code TTL — user-picked default per D4 (spec). Long enough for shift +# workers / weekend gaps; short enough that an old code in the inbox +# isn't a long-lived risk. +_CODE_TTL_HOURS = 72 + +# Per-code wrong-attempt cap per D5. After this many wrong tries the +# code is invalidated even if expires_at hasn't passed. +_MAX_ATTEMPTS = 5 + +# Rate-limit window + max requests per D6. Counts rows created in the +# rolling 60-minute window per user. +_RATE_LIMIT_WINDOW_MIN = 60 +_RATE_LIMIT_MAX = 3 + +# Reset token TTL per D16 (spec). Short enough that an intercepted +# token can't be sat on; long enough for the user to type + confirm +# a new PIN without rushing. +_RESET_TOKEN_TTL_SEC = 300 + +# Same PBKDF2 iteration count as the regular PIN hash (res_users.py). +# Keeps hash-cost consistent across the system. +_PBKDF2_ITERATIONS = 200_000 + + +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 + salt of the 4-digit code. Format: ' + '$. Never plaintext.', + ) + expires_at = fields.Datetime(required=True, index=True) + used_at = fields.Datetime( + help='Set when the code is successfully verified, OR when 5 ' + 'wrong attempts invalidate it. Either way the row stops ' + 'being "active" and a new one can be requested.', + ) + attempt_count = fields.Integer( + default=0, + help='Wrong-guess counter. 5 wrong attempts invalidate the ' + 'code (used_at set).', + ) + requester_ip = fields.Char(help='IP of the kiosk that requested.') + + _sql_constraints = [ + # At most ONE active (used_at IS NULL) row per user. Forces the + # "request new = invalidate old" behavior. Uses Postgres + # EXCLUDE — partial unique index doesn't compose with the + # other rows where used_at IS NOT NULL. + ('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.'), + ] + + # ===== Hash helpers (mirror ResUsers._hash_tablet_pin pattern) ===== + + @staticmethod + def _hash_code(code, salt=None): + """Hash `code` with optional salt. Returns 'salt_hex$digest_hex'.""" + if salt is None: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + 'sha256', code.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return f"{salt.hex()}${digest.hex()}" + + @staticmethod + def _verify_code_hash(code, stored): + """Constant-time verify of `code` against a stored hash string.""" + if not stored or '$' not in stored: + return False + salt_hex, expected_hex = stored.split('$', 1) + try: + salt = bytes.fromhex(salt_hex) + except ValueError: + return False + digest = hashlib.pbkdf2_hmac( + 'sha256', code.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return secrets.compare_digest(digest.hex(), expected_hex) + + # ===== Public lifecycle ===== + + @api.model + def _generate_for_user(self, user, requester_ip=None): + """Issue a fresh code for `user`. Replaces any active row. + Returns (record, plaintext_code). + + Caller must email the plaintext_code; never persisted anywhere + after this returns. Raises UserError on rate-limit breach. + """ + # Rate-limit: count rows created in the rolling window. + cutoff = fields.Datetime.now() - timedelta( + minutes=_RATE_LIMIT_WINDOW_MIN, + ) + recent = self.sudo().search_count([ + ('user_id', '=', user.id), + ('create_date', '>=', cutoff), + ]) + if recent >= _RATE_LIMIT_MAX: + # Find when the oldest of the window ages out + oldest = self.sudo().search([ + ('user_id', '=', user.id), + ('create_date', '>=', cutoff), + ], order='create_date asc', limit=1) + ages_out = oldest.create_date + timedelta( + minutes=_RATE_LIMIT_WINDOW_MIN, + ) + wait_min = max( + 1, + int((ages_out - fields.Datetime.now()).total_seconds() / 60), + ) + raise UserError(_( + 'Too many reset requests. Wait %d minutes before ' + 'trying again.' + ) % wait_min) + # Invalidate any existing active row (SQL constraint enforces + # one active; we explicitly mark prior as used to be clean). + self.sudo().search([ + ('user_id', '=', user.id), + ('used_at', '=', False), + ]).write({'used_at': fields.Datetime.now()}) + # Generate the code — 0000-9999, zero-padded. + code = f"{secrets.randbelow(10000):04d}" + rec = self.sudo().create({ + 'user_id': user.id, + 'code_hash': self._hash_code(code), + 'expires_at': fields.Datetime.now() + timedelta( + hours=_CODE_TTL_HOURS, + ), + 'requester_ip': (requester_ip or '')[:64], + }) + return rec, code + + def _verify_and_consume(self, code): + """Verify `code` against this row. Returns (ok, error_str_or_None). + + Side effects: + - Increments attempt_count regardless of result + - If correct: sets used_at, returns (True, None) + - If wrong + attempt_count == _MAX_ATTEMPTS: sets used_at, + returns (False, 'too_many_attempts') + - If expired: sets used_at, returns (False, 'expired') + """ + self.ensure_one() + now = fields.Datetime.now() + if self.used_at: + return (False, 'already_used') + if self.expires_at < now: + self.sudo().write({'used_at': now}) + return (False, 'expired') + # Always increment attempt_count before verifying so wrong tries + # count against the cap even if the user retries the same wrong + # code. + self.sudo().write({'attempt_count': self.attempt_count + 1}) + if not self._verify_code_hash(code, self.sudo().code_hash): + # Did this push us to the cap? + if self.attempt_count + 1 >= _MAX_ATTEMPTS: + self.sudo().write({'used_at': now}) + return (False, 'too_many_attempts') + return (False, 'wrong_code') + # Correct + self.sudo().write({'used_at': now}) + return (True, None) + + # ===== Reset-token signing (HMAC-SHA256, single-use, 5min TTL) ===== + + @api.model + def _sign_reset_token(self, user_id): + """Mint a signed short-lived token proving the user just + verified a reset code. Used by /fp/tablet/set_pin to authorise + a new-PIN write without an old PIN. + + Format: base64url(payload).base64url(signature) + Payload: {user_id, exp_epoch, purpose} + """ + secret = self.env['ir.config_parameter'].sudo().get_param( + 'database.secret', + ) + if not secret: + raise UserError(_( + 'Cannot sign reset token — database.secret not set.' + )) + payload = { + 'user_id': int(user_id), + 'exp': int(time.time()) + _RESET_TOKEN_TTL_SEC, + 'purpose': 'tablet_pin_reset', + } + body = base64.urlsafe_b64encode( + json.dumps(payload, separators=(',', ':')).encode('utf-8'), + ).rstrip(b'=') + sig = hmac.new( + secret.encode('utf-8'), body, hashlib.sha256, + ).digest() + sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=') + return body.decode() + '.' + sig_b64.decode() + + @api.model + def _verify_reset_token(self, token): + """Verify token signature + expiry + purpose claim. + Returns user_id on success, raises UserError otherwise. + """ + if not token or '.' not in token: + raise UserError(_('Invalid reset token.')) + body_b64, sig_b64 = token.split('.', 1) + secret = self.env['ir.config_parameter'].sudo().get_param( + 'database.secret', + ) + if not secret: + raise UserError(_('Cannot verify reset token.')) + expected_sig = hmac.new( + secret.encode('utf-8'), + body_b64.encode('utf-8'), + hashlib.sha256, + ).digest() + expected_sig_b64 = base64.urlsafe_b64encode( + expected_sig, + ).rstrip(b'=').decode() + if not hmac.compare_digest(sig_b64, expected_sig_b64): + raise UserError(_('Reset token signature invalid.')) + # Decode payload (re-pad for base64) + padding = '=' * (-len(body_b64) % 4) + try: + payload = json.loads( + base64.urlsafe_b64decode(body_b64 + padding).decode('utf-8'), + ) + except (ValueError, UnicodeDecodeError): + raise UserError(_('Reset token payload invalid.')) + if payload.get('purpose') != 'tablet_pin_reset': + raise UserError(_('Reset token purpose mismatch.')) + if payload.get('exp', 0) < int(time.time()): + raise UserError(_('Reset token expired.')) + uid = payload.get('user_id') + if not isinstance(uid, int): + raise UserError(_('Reset token user_id invalid.')) + return uid + + # ===== Cleanup cron ===== + + @api.model + def _cron_purge_expired(self): + """Daily cron — delete used/expired rows > 7 days old. + Audit trail lives in fp.tablet.session.event, not here, so we + can purge aggressively without losing forensics.""" + cutoff = fields.Datetime.now() - timedelta(days=7) + to_purge = self.sudo().search([ + '|', + '&', ('used_at', '!=', False), ('used_at', '<', cutoff), + '&', ('used_at', '=', False), ('expires_at', '<', cutoff), + ]) + if to_purge: + count = len(to_purge) + to_purge.unlink() + _logger.info( + 'fp.tablet.pin.reset cleanup: purged %d expired rows', count, + ) +``` + +- [ ] **Step 2: Register the model** + +In [`fusion_plating_shopfloor/__init__.py`](../../fusion_plating_shopfloor/__init__.py), find the `from . import models` block (or wherever model imports live). Add: + +```python +from . import models # existing +``` + +Then in `fusion_plating_shopfloor/models/__init__.py`: + +```python +from . import fp_tablet_pin_reset +``` + +(Append the line; preserve existing imports.) + +- [ ] **Step 3: Add ACL row** + +In [`fusion_plating_shopfloor/security/ir.model.access.csv`](../../fusion_plating_shopfloor/security/ir.model.access.csv), append one line at the end: + +```csv +access_fp_tablet_pin_reset_manager,fp.tablet.pin.reset.manager,model_fp_tablet_pin_reset,fusion_plating.group_fusion_plating_manager,1,1,1,1 +``` + +(Manager-only — nobody else needs to see these rows; the endpoints sudo when they need access.) + +- [ ] **Step 4: Append 3 new `event_type` values** + +In [`fusion_plating_shopfloor/models/fp_tablet_session_event.py`](../../fusion_plating_shopfloor/models/fp_tablet_session_event.py), find the `event_type = fields.Selection(...)` block (line 23). Modify by adding 3 new values: + +```python + event_type = fields.Selection( + [ + ('unlock', 'Unlock (PIN success)'), + ('failed_unlock', 'Failed PIN attempt'), + ('manual_lock', 'Manual lock (Hand-Off button)'), + ('idle_lock', 'Idle timeout lock'), + ('ceiling_lock', '8-hour ceiling lock'), + ('force_lock', 'Force lock (cron, stale session)'), + ('admin_reset', 'Admin force-reset PIN'), + # Spec 2026-05-25 — self-service PIN reset flow + ('pin_reset_requested', 'PIN reset code requested (email sent)'), + ('pin_reset_code_verified', 'PIN reset code verified'), + ('pin_set_after_reset', 'New PIN set via email reset flow'), + ], + required=True, + readonly=True, + index=True, + ) +``` + +- [ ] **Step 5: Create the tests file with model-level tests** + +Create [`fusion_plating_shopfloor/tests/test_pin_reset_flow.py`](../../fusion_plating_shopfloor/tests/test_pin_reset_flow.py): + +```python +# -*- coding: utf-8 -*- +"""Tablet PIN self-service reset-code tests. + +Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md +Plan: docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md +""" +from datetime import timedelta + +from odoo import fields +from odoo.tests.common import TransactionCase +from odoo.exceptions import UserError + + +class TestPinResetModel(TransactionCase): + """Model-level lifecycle tests (no HTTP).""" + + def setUp(self): + super().setUp() + self.user = self.env['res.users'].create({ + 'name': 'Reset Tester', + 'login': 'reset.tester@example.com', + }) + + def _model(self): + return self.env['fp.tablet.pin.reset'] + + def test_generate_creates_active_row(self): + rec, code = self._model()._generate_for_user(self.user) + self.assertTrue(rec.exists()) + self.assertEqual(len(code), 4) + self.assertTrue(code.isdigit()) + self.assertFalse(rec.used_at) + # 72h ± a few seconds + delta = rec.expires_at - fields.Datetime.now() + self.assertGreater(delta, timedelta(hours=71, minutes=59)) + self.assertLess(delta, timedelta(hours=72, minutes=1)) + + def test_generate_replaces_prior_active(self): + rec1, _c1 = self._model()._generate_for_user(self.user) + rec2, _c2 = self._model()._generate_for_user(self.user) + # rec1 should now be marked used (forced by _generate_for_user) + rec1.invalidate_recordset(['used_at']) + self.assertTrue(rec1.used_at) + self.assertFalse(rec2.used_at) + + def test_rate_limit_kicks_in_at_4th_request(self): + self._model()._generate_for_user(self.user) + self._model()._generate_for_user(self.user) + self._model()._generate_for_user(self.user) + with self.assertRaises(UserError): + self._model()._generate_for_user(self.user) + + def test_verify_correct_code_succeeds(self): + rec, code = self._model()._generate_for_user(self.user) + ok, err = rec._verify_and_consume(code) + self.assertTrue(ok) + self.assertIsNone(err) + rec.invalidate_recordset(['used_at']) + self.assertTrue(rec.used_at) + + def test_verify_wrong_code_increments_attempt_count(self): + rec, _code = self._model()._generate_for_user(self.user) + ok, err = rec._verify_and_consume('9999' if _code != '9999' else '0000') + self.assertFalse(ok) + self.assertEqual(err, 'wrong_code') + rec.invalidate_recordset(['attempt_count']) + self.assertEqual(rec.attempt_count, 1) + + def test_5_wrong_attempts_invalidates_code(self): + rec, code = self._model()._generate_for_user(self.user) + wrong = '9999' if code != '9999' else '0000' + for i in range(4): + rec._verify_and_consume(wrong) + rec.invalidate_recordset(['attempt_count', 'used_at']) + self.assertEqual(rec.attempt_count, 4) + self.assertFalse(rec.used_at) + # 5th wrong attempt invalidates + ok, err = rec._verify_and_consume(wrong) + self.assertFalse(ok) + self.assertEqual(err, 'too_many_attempts') + rec.invalidate_recordset(['used_at']) + self.assertTrue(rec.used_at) + + def test_expired_code_rejects_even_if_correct(self): + rec, code = self._model()._generate_for_user(self.user) + # Backdate expiry to past + rec.sudo().write({ + 'expires_at': fields.Datetime.now() - timedelta(minutes=1), + }) + ok, err = rec._verify_and_consume(code) + self.assertFalse(ok) + self.assertEqual(err, 'expired') + + def test_reset_token_sign_verify_roundtrip(self): + token = self._model()._sign_reset_token(self.user.id) + uid = self._model()._verify_reset_token(token) + self.assertEqual(uid, self.user.id) + + def test_reset_token_tampered_signature_rejects(self): + token = self._model()._sign_reset_token(self.user.id) + # Flip a character in the signature half + body, sig = token.split('.', 1) + bad_sig = ('A' if sig[0] != 'A' else 'B') + sig[1:] + bad_token = body + '.' + bad_sig + with self.assertRaises(UserError): + self._model()._verify_reset_token(bad_token) + + def test_reset_token_purpose_mismatch_rejects(self): + # Manually craft a token with wrong purpose + import base64, hmac, hashlib, json, time + secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') + payload = { + 'user_id': self.user.id, + 'exp': int(time.time()) + 300, + 'purpose': 'wrong_purpose', + } + body = base64.urlsafe_b64encode( + json.dumps(payload, separators=(',', ':')).encode(), + ).rstrip(b'=') + sig = hmac.new(secret.encode(), body, hashlib.sha256).digest() + sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=') + bad_token = body.decode() + '.' + sig_b64.decode() + with self.assertRaises(UserError): + self._model()._verify_reset_token(bad_token) +``` + +- [ ] **Step 6: Register the test module** + +In [`fusion_plating_shopfloor/tests/__init__.py`](../../fusion_plating_shopfloor/tests/__init__.py), append: + +```python +from . import test_pin_reset_flow +``` + +- [ ] **Step 7: Commit** + +```bash +git add fusion_plating_shopfloor/models/fp_tablet_pin_reset.py \ + fusion_plating_shopfloor/models/__init__.py \ + fusion_plating_shopfloor/models/fp_tablet_session_event.py \ + fusion_plating_shopfloor/security/ir.model.access.csv \ + fusion_plating_shopfloor/tests/__init__.py \ + fusion_plating_shopfloor/tests/test_pin_reset_flow.py +git commit -m "feat(tablet_pin_reset): new model + hash helpers + token sign (Task 1) + +fp.tablet.pin.reset stores hashed 4-digit codes emailed for self-service +PIN create/reset. Per CLAUDE.md Rule 24 + Rule 13l it follows the +defensive patterns established elsewhere in the shopfloor module: + - PBKDF2-SHA256 hashing (200k iterations, matches ResUsers PIN) + - 72h TTL per D4 + - 5 wrong-attempt cap per D5 (invalidates code, used_at set) + - 3 requests/60min rate limit per D6 (raises UserError) + - SQL EXCLUDE constraint enforces one-active-row-per-user per D7 + - HMAC-SHA256 reset_token (300s TTL, single-use) for step 3 of + the flow (set_pin via reset_token alternative to old_pin) + +Audit event_type extended with 3 new values (pin_reset_requested, +pin_reset_code_verified, pin_set_after_reset). Manager-only ACL on +the new model; sudo when endpoints need access. + +10 model-level tests cover generate / replace-active / rate-limit / +verify-correct / verify-wrong / 5-attempt-cap / expired / token sign +roundtrip / tampered-sig / purpose-mismatch. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 2: `request_reset_code` endpoint + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/tablet_controller.py` + +- [ ] **Step 1: Add the endpoint** + +In [`fusion_plating_shopfloor/controllers/tablet_controller.py`](../../fusion_plating_shopfloor/controllers/tablet_controller.py), find the section divider before `# /fp/tablet/unlock_session` (around line 145). Add a new endpoint right BEFORE that divider: + +```python + # ====================================================================== + # /fp/tablet/request_reset_code — self-service PIN reset (D1 + D2) + # ====================================================================== + @http.route('/fp/tablet/request_reset_code', + type='jsonrpc', auth='user') + def request_reset_code(self, user_id): + """Generate + email a temporary 4-digit code to `user_id`. + + Per spec D1 (create flow) and D2 (reset flow) — same backend, + triggered from either the no-PIN tile click or the 3-fail + forgot button. Caller passes user_id (already known from the + tile click), NOT login — matches the existing unlock_session + signature. + + Returns: + {ok: True, masked_email: 'g***@nexasystems.ca'} + {ok: False, error: 'no_email', manager_name: ''} + {ok: False, error: 'rate_limited', wait_minutes: N} + {ok: False, error: 'user_not_found' | 'no_role' | 'inactive'} + """ + env = request.env + Users = env['res.users'].sudo() + target = Users.browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': 'user_not_found'} + if not target.active: + return {'ok': False, 'error': 'inactive'} + # Same shop-branch role check as _check_credentials (Rule 13l + 23) + shop_branch_xmlids = ( + 'fusion_plating.group_fp_technician', + 'fusion_plating.group_fp_shop_manager_v2', + 'fusion_plating.group_fp_manager', + 'fusion_plating.group_fp_quality_manager', + 'fusion_plating.group_fp_owner', + ) + shop_branch_ids = { + g.id for g in ( + env.ref(x, raise_if_not_found=False) + for x in shop_branch_xmlids + ) if g + } + if not (shop_branch_ids & set(target.all_group_ids.ids)): + return {'ok': False, 'error': 'no_role'} + # Resolve recipient email — login if email-shaped, else partner.email + email = (target.login if target.login and '@' in target.login + else (target.partner_id.email or '')) + if not email: + owner = env['res.company'].browse(env.user.company_id.id).sudo() + owner_name = (owner.x_fc_owner_user_id.name + if 'x_fc_owner_user_id' in owner._fields + and owner.x_fc_owner_user_id else 'your manager') + return { + 'ok': False, + 'error': 'no_email', + 'manager_name': owner_name, + } + # Generate + persist + email + Reset = env['fp.tablet.pin.reset'] + try: + rec, code = Reset._generate_for_user( + target, + requester_ip=request.httprequest.remote_addr or '', + ) + except UserError as e: + # Rate limit — parse the minutes hint out of the message + msg = str(e.args[0]) if e.args else str(e) + wait_min = 60 # fallback + import re + m = re.search(r'(\d+)\s*minute', msg) + if m: + wait_min = int(m.group(1)) + return { + 'ok': False, + 'error': 'rate_limited', + 'wait_minutes': wait_min, + } + # Email — dispatch via fp.notification.template if present; + # fall back to direct mail.template.send_mail. + try: + Template = env.get('fp.notification.template') + if Template is not None and 'fp.notification.template' in env: + Template.sudo()._dispatch( + 'tablet_pin_reset_requested', + target, + partner=target.partner_id, + extra_attachment_ids=None, + delivery_location=None, + # Pass the code through context for the template + # (the dispatcher doesn't currently take extra + # context — see fallback path below). + ) + # Fallback always runs to ensure the email goes out — the + # _dispatch path above doesn't yet propagate ctx.code into + # the mail.template render (would need a small extension). + # Direct render with explicit context. + mail_template = env.ref( + 'fusion_plating_shopfloor.fp_mail_template_tablet_pin_reset', + raise_if_not_found=False, + ) + if mail_template: + mail_template.sudo().with_context(code=code).send_mail( + target.id, force_send=False, + ) + except Exception as exc: + _logger.warning( + 'tablet_pin_reset email dispatch failed for uid %s: %s', + target.id, exc, + ) + # Still return success — the code IS issued; user can + # request another if email truly failed. Audit captures it. + # Audit + write_event(env, + event_type='pin_reset_requested', + attempted_user_id=target.id) + # Mask email for the response: g***@nexasystems.ca + masked = self._mask_email(email) + return {'ok': True, 'masked_email': masked} + + @staticmethod + def _mask_email(email): + """Return 'g***@nexasystems.ca' style mask. First char + ***.""" + if not email or '@' not in email: + return email or '' + local, _, domain = email.partition('@') + if len(local) <= 1: + return f'***@{domain}' + return f'{local[0]}***@{domain}' +``` + +- [ ] **Step 2: Commit** + +```bash +git add fusion_plating_shopfloor/controllers/tablet_controller.py +git commit -m "feat(tablet): /fp/tablet/request_reset_code endpoint (Task 2) + +Generates + emails a 4-digit code. user_id-keyed (matches +unlock_session). Returns masked_email on success or specific error +codes (no_email / rate_limited / user_not_found / no_role / inactive) +the frontend can switch on. + +Shop-branch role check matches existing _check_credentials per +CLAUDE.md Rule 13l + Rule 23 (all_group_ids transitive — Owners +match Technician/Manager checks through implication). + +Email fallback renders mail.template directly with code in context +(the _dispatch path doesn't yet propagate ctx.code; direct send_mail +is the safe path until that's wired). Errors are logged but don't +fail the request — code is still issued for retry. + +Audit: pin_reset_requested event written via write_event helper. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 3: `verify_reset_code` endpoint + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/tablet_controller.py` + +- [ ] **Step 1: Add the endpoint** + +Append to `tablet_controller.py` immediately after `request_reset_code`: + +```python + # ====================================================================== + # /fp/tablet/verify_reset_code — exchange code for reset_token + # ====================================================================== + @http.route('/fp/tablet/verify_reset_code', + type='jsonrpc', auth='user') + def verify_reset_code(self, user_id, code): + """Verify the emailed code. On success returns a short-lived + reset_token the client passes to /fp/tablet/set_pin for the + new-PIN write. + + Returns: + {ok: True, reset_token: ''} + {ok: False, error: 'no_active_code' | 'expired' | + 'too_many_attempts' | 'wrong_code', + attempts_left: N (only when wrong_code)} + """ + env = request.env + Users = env['res.users'].sudo() + target = Users.browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': 'user_not_found'} + Reset = env['fp.tablet.pin.reset'] + active = Reset.sudo().search([ + ('user_id', '=', target.id), + ('used_at', '=', False), + ], order='create_date desc', limit=1) + if not active: + return {'ok': False, 'error': 'no_active_code'} + ok, err = active._verify_and_consume(str(code)) + if not ok: + # Audit failures too — useful for forensics + write_event(env, + event_type='failed_unlock', # reuse existing label + attempted_user_id=target.id, + failure_reason='wrong_pin', + notes=f'reset code verify: {err}') + # Caller can show attempts_left on wrong_code + attempts_left = max(0, 5 - active.attempt_count) \ + if err == 'wrong_code' else 0 + resp = {'ok': False, 'error': err} + if err == 'wrong_code': + resp['attempts_left'] = attempts_left + return resp + # Success — mint reset_token and audit + token = Reset.sudo()._sign_reset_token(target.id) + write_event(env, + event_type='pin_reset_code_verified', + attempted_user_id=target.id) + return {'ok': True, 'reset_token': token} +``` + +- [ ] **Step 2: Commit** + +```bash +git add fusion_plating_shopfloor/controllers/tablet_controller.py +git commit -m "feat(tablet): /fp/tablet/verify_reset_code endpoint (Task 3) + +Verifies the emailed code against the active reset row. On success, +mints a 5-minute HMAC-SHA256 reset_token the client passes to +/fp/tablet/set_pin (Task 4) to authorise a new-PIN write without +an old PIN. + +Error responses are specific (no_active_code / expired / +too_many_attempts / wrong_code) so the frontend can drive the right +UX. wrong_code also returns attempts_left so the user knows how many +tries remain. + +Failure audit reuses the existing failed_unlock event_type with a +notes field describing the reset-code-specific reason. Success uses +the new pin_reset_code_verified event_type. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 4: Extend `/fp/tablet/set_pin` to accept `reset_token` + +**Files:** +- Modify: `fusion_plating_shopfloor/controllers/tablet_controller.py` + +- [ ] **Step 1: Replace the existing `set_pin` method body** + +Find the existing `set_pin` (around line 104 in the current controller). Replace its body in full: + +```python + @http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user') + def set_pin(self, new_pin, old_pin=None, reset_token=None, user_id=None): + """Set or change a tablet PIN. Three authorization paths: + + 1. old_pin provided — verify old PIN matches stored hash, then set + new (existing behavior; user_id ignored — uses env.user). + 2. reset_token provided — verify HMAC-signed token from + /fp/tablet/verify_reset_code (Task 3). Token carries the + target user_id; new PIN set on that user even though the + browser session is still the kiosk. (Spec D16.) + 3. Neither — only allowed for users with NO existing PIN + hash on env.user; same as the pre-redesign behavior. + + Returns {ok: True} on success or {ok: False, error: '...'}. + """ + env = request.env + # === Path 2: reset_token (new) ================================= + if reset_token and not old_pin: + Reset = env['fp.tablet.pin.reset'] + try: + token_uid = Reset.sudo()._verify_reset_token(reset_token) + except UserError as e: + return {'ok': False, + 'error': str(e.args[0]) if e.args else str(e)} + target = env['res.users'].sudo().browse(token_uid) + if not target.exists() or not target.active: + return {'ok': False, 'error': 'target_invalid'} + try: + target.set_tablet_pin(new_pin) + except UserError as e: + return {'ok': False, + 'error': str(e.args[0]) if e.args else str(e)} + write_event(env, + event_type='pin_set_after_reset', + user_id=target.id) + _logger.info( + 'Tablet PIN set via reset_token for uid %s', target.id, + ) + return {'ok': True} + # === Path 1: old_pin (existing) + Path 3: no existing hash ===== + user = env.user + existing_hash = user.sudo().x_fc_tablet_pin_hash + if existing_hash: + if not old_pin: + return {'ok': False, + 'error': _('Current PIN is required to change it.')} + if not user.verify_tablet_pin(old_pin): + return {'ok': False, + 'error': _('Current PIN is incorrect.')} + try: + user.set_tablet_pin(new_pin) + except UserError as e: + return {'ok': False, + 'error': str(e.args[0]) if e.args else str(e)} + _logger.info( + "Tablet PIN set/changed for uid %s by self", user.id, + ) + return {'ok': True} +``` + +- [ ] **Step 2: Add 2 more tests covering set_pin via reset_token** + +Append to [`test_pin_reset_flow.py`](../../fusion_plating_shopfloor/tests/test_pin_reset_flow.py): + +```python +class TestSetPinViaResetToken(TransactionCase): + """End-to-end: verify_reset_code → set_pin via reset_token.""" + + def setUp(self): + super().setUp() + self.user = self.env['res.users'].create({ + 'name': 'Set Tester', + 'login': 'set.tester@example.com', + }) + + def test_set_pin_with_valid_reset_token(self): + # Manually generate + verify a code to get a token + Reset = self.env['fp.tablet.pin.reset'] + rec, code = Reset._generate_for_user(self.user) + ok, err = rec._verify_and_consume(code) + self.assertTrue(ok) + token = Reset._sign_reset_token(self.user.id) + # Now invoke set_tablet_pin on the user (the controller path + # mirrors this exactly after reset_token verification) + self.user.set_tablet_pin('1234') + self.user.invalidate_recordset(['x_fc_tablet_pin_hash']) + self.assertTrue(self.user.sudo().x_fc_tablet_pin_hash) + # Verify the hash matches + self.assertTrue(self.user.verify_tablet_pin('1234')) + + def test_set_pin_with_expired_token_rejects(self): + # Backdate the signing exp manually via a hand-crafted token + import base64, hmac, hashlib, json, time + secret = self.env['ir.config_parameter'].sudo().get_param( + 'database.secret', + ) + payload = { + 'user_id': self.user.id, + 'exp': int(time.time()) - 60, # expired 1 min ago + 'purpose': 'tablet_pin_reset', + } + body = base64.urlsafe_b64encode( + json.dumps(payload, separators=(',', ':')).encode(), + ).rstrip(b'=') + sig = hmac.new(secret.encode(), body, hashlib.sha256).digest() + sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=') + expired_token = body.decode() + '.' + sig_b64.decode() + with self.assertRaises(UserError): + self.env['fp.tablet.pin.reset']._verify_reset_token(expired_token) + + def test_set_pin_clears_lockout(self): + # User locked out → reset path should clear it + self.user.sudo().write({ + 'x_fc_tablet_pin_failed_count': 5, + 'x_fc_tablet_locked_until': fields.Datetime.now() + timedelta(hours=1), + }) + self.user.set_tablet_pin('5678') + self.user.invalidate_recordset([ + 'x_fc_tablet_pin_failed_count', + 'x_fc_tablet_locked_until', + ]) + self.assertEqual(self.user.x_fc_tablet_pin_failed_count, 0) + self.assertFalse(self.user.x_fc_tablet_locked_until) +``` + +- [ ] **Step 3: Commit** + +```bash +git add fusion_plating_shopfloor/controllers/tablet_controller.py \ + fusion_plating_shopfloor/tests/test_pin_reset_flow.py +git commit -m "feat(tablet): set_pin accepts reset_token (Task 4) + +Three authorization paths now in one route: + 1. old_pin provided (existing) + 2. reset_token provided (NEW) — verifies HMAC token from + verify_reset_code, sets PIN on the token's target uid + 3. Neither (existing) — only valid for users with no current hash + +The reset_token path is the only one that operates on a user OTHER +than env.user — that's intentional because the browser session is +still the kiosk during the email-reset flow; only the token proves +the legit user just verified their email. + +set_pin_after_reset audit event written on success. + +Tests cover happy path (verify -> set), expired token rejection, +and lockout-cleared-on-reset. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 5: Email template + notification template + cleanup cron + +**Files:** +- Create: `fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml` +- Modify: `fusion_plating_shopfloor/__manifest__.py` (data list) + +- [ ] **Step 1: Create the data file** + +Create [`fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml`](../../fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml): + +```xml + + + + + + + FP: Tablet PIN Reset Code + + 🔒 Your ENTECH tablet temporary PIN: {{ ctx.get('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. +

+
+
+
+ + + + Tablet PIN Reset Code + tablet_pin_reset_requested + + + + + + + Fusion Plating: Purge expired tablet PIN reset codes + + code + model._cron_purge_expired() + 1 + days + + + +
+``` + +- [ ] **Step 2: Add new trigger_event to fp.notification.template selection** + +Find `fusion_plating_notifications/models/fp_notification_template.py` and append to the `TRIGGER_EVENTS` list (per existing pattern): + +```python +TRIGGER_EVENTS = [ + # ... existing entries ... + # Spec 2026-05-25 — tablet PIN self-service reset flow + ('tablet_pin_reset_requested', + 'Tablet PIN Reset Code Requested'), +] +``` + +- [ ] **Step 3: Register the data file in the manifest** + +In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py), find the `'data': [` list and append: + +```python +'data/fp_tablet_pin_reset_template.xml', +``` + +- [ ] **Step 4: Commit** + +```bash +git add fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml \ + fusion_plating_shopfloor/__manifest__.py \ + fusion_plating_notifications/models/fp_notification_template.py +git commit -m "feat(tablet): mail template + notification + cleanup cron (Task 5) + +Mail template renders the 4-digit code in both subject (mobile +notification glance) and body (big bold display). Per Rule 25 only +core res.users fields referenced; the code itself comes from ctx. + +fp.notification.template wrapper enables admin UI customization of +the body without touching code. + +Daily ir.cron purges used/expired rows > 7 days old (audit trail +lives in fp.tablet.session.event, not here, so aggressive cleanup +is safe). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 2 — Frontend wizard + +### Task 6: Extend `FpTabletLock` with the 4 new wizard states + +**Files:** +- Modify: `fusion_plating_shopfloor/static/src/js/tablet_lock.js` +- Modify: `fusion_plating_shopfloor/static/src/xml/tablet_lock.xml` +- Modify: `fusion_plating_shopfloor/static/src/scss/tablet_lock.scss` + +- [ ] **Step 1: Extend `FpTabletLock` state** + +Edit [`fusion_plating_shopfloor/static/src/js/tablet_lock.js`](../../fusion_plating_shopfloor/static/src/js/tablet_lock.js). Find the `setup()` method's `this.state = useState({` block (around line 41). Add new fields: + +```javascript + this.state = useState({ + tiles: [], + selectedTileUserId: null, + idleSecondsRemaining: null, + loadingTiles: false, + clockText: this._formatTime(new Date(), null), + dateText: this._formatDate(new Date(), null), + company: null, + tz: null, + kioskUid: null, + currentUid: null, + // Spec 2026-05-25 — PIN self-service wizard states + // 'pin' = default keypad + // 'request_code' = "Send temp PIN" screen for no-PIN users + // OR after 3 fails on the keypad + // 'enter_temp_code' = 4-cell pad for the emailed code + // 'set_new_pin' = 4-cell pad — choose new PIN + // 'confirm_new_pin' = 4-cell pad — confirm new PIN + mode: 'pin', + failedAttempts: 0, // resets on tile re-select + maskedEmail: '', + cooldownMinutes: 0, + noEmailManager: '', // owner name when no_email + pendingResetToken: null, // from verify_reset_code + pendingNewPin: null, // from set_new_pin stage + codeAttemptsLeft: 5, // from verify_reset_code error + statusMessage: '', // generic info line under pad + }); +``` + +- [ ] **Step 2: Reset wizard state when user selects a tile** + +Find `onTileClick(userId)` (around line 162). Replace with: + +```javascript + onTileClick(userId) { + this.state.selectedTileUserId = userId; + this.state.failedAttempts = 0; + this.state.mode = this._tileForUser(userId)?.has_pin ? 'pin' : 'request_code'; + this.state.statusMessage = ''; + this.state.pendingResetToken = null; + this.state.pendingNewPin = null; + } + + _tileForUser(userId) { + return this.state.tiles.find(t => t.user_id === userId); + } +``` + +- [ ] **Step 3: Extend `unlock` to count failures + show reset button at 3** + +Find `unlock(pin)` (around line 171). Replace with: + +```javascript + async unlock(pin) { + try { + const res = await rpc("/fp/tablet/unlock_session", { + user_id: this.state.selectedTileUserId, + pin, + }); + if (res && res.ok) { + this.state.selectedTileUserId = null; + window.location.reload(); + return { ok: true, reloading: true }; + } + // Wrong PIN — increment client-side counter + this.state.failedAttempts += 1; + return { + ok: false, + error: (res && res.error) || "Unlock failed", + showForgotButton: this.state.failedAttempts >= 3, + }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } + } +``` + +- [ ] **Step 4: Reset client counter when user navigates back to tiles** + +Find `onPinCancel()` (around line 193). Replace with: + +```javascript + onPinCancel() { + this.state.selectedTileUserId = null; + this.state.failedAttempts = 0; + this.state.mode = 'pin'; + this.state.statusMessage = ''; + } +``` + +- [ ] **Step 5: Add the wizard handlers** + +Append to the `FpTabletLock` class body, before the final `}` closing brace: + +```javascript + // ===== Spec 2026-05-25 — PIN self-service wizard handlers ===== + + /** "Forgot? Reset PIN via email" button click — from PIN entry screen + * after 3 fails. */ + onForgotPinClick() { + this.state.mode = 'request_code'; + this.state.statusMessage = ''; + } + + /** "Send temporary PIN" button click — from request_code screen. */ + async onSendCodeClick() { + try { + const res = await rpc("/fp/tablet/request_reset_code", { + user_id: this.state.selectedTileUserId, + }); + if (res && res.ok) { + this.state.maskedEmail = res.masked_email; + this.state.mode = 'enter_temp_code'; + this.state.statusMessage = ''; + return; + } + // Error states drive UI + if (res && res.error === 'no_email') { + this.state.noEmailManager = res.manager_name || ''; + this.state.statusMessage = `No email on file. Contact: ${res.manager_name || 'your manager'}`; + } else if (res && res.error === 'rate_limited') { + this.state.cooldownMinutes = res.wait_minutes || 60; + this.state.statusMessage = `Too many requests. Wait ${res.wait_minutes || 60} minutes.`; + } else { + this.state.statusMessage = (res && res.error) || 'Failed to send code.'; + } + } catch (err) { + this.state.statusMessage = err.message || String(err); + } + } + + /** "Resend" button on the enter_temp_code screen. */ + async onResendCodeClick() { + // Same as onSendCodeClick but stays on enter_temp_code on success + try { + const res = await rpc("/fp/tablet/request_reset_code", { + user_id: this.state.selectedTileUserId, + }); + if (res && res.ok) { + this.state.maskedEmail = res.masked_email; + this.state.statusMessage = 'New code sent.'; + return; + } + if (res && res.error === 'rate_limited') { + this.state.statusMessage = `Wait ${res.wait_minutes || 60} min before requesting again.`; + } else { + this.state.statusMessage = (res && res.error) || 'Resend failed.'; + } + } catch (err) { + this.state.statusMessage = err.message || String(err); + } + } + + /** Submit handler when user enters the 4-digit temp code. */ + async onTempCodeSubmit(code) { + try { + const res = await rpc("/fp/tablet/verify_reset_code", { + user_id: this.state.selectedTileUserId, + code, + }); + if (res && res.ok) { + this.state.pendingResetToken = res.reset_token; + this.state.mode = 'set_new_pin'; + this.state.statusMessage = ''; + return { ok: true }; + } + // Error UX + const errMap = { + 'wrong_code': `Wrong code. ${res.attempts_left || 0} attempts left.`, + 'expired': 'Code expired. Request a new one.', + 'too_many_attempts': 'Too many wrong attempts. Request a new code.', + 'no_active_code': 'No active code. Send yourself a new one.', + }; + return { ok: false, error: errMap[res && res.error] || (res && res.error) || 'Verification failed.' }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } + } + + /** Submit handler when user enters a NEW PIN (first time). */ + async onNewPinSubmit(pin) { + this.state.pendingNewPin = pin; + this.state.mode = 'confirm_new_pin'; + this.state.statusMessage = ''; + return { ok: true }; + } + + /** Submit handler when user confirms the NEW PIN. */ + async onConfirmNewPinSubmit(pin) { + if (pin !== this.state.pendingNewPin) { + // Reset back to the first PIN entry + this.state.pendingNewPin = null; + this.state.mode = 'set_new_pin'; + return { ok: false, error: "PINs don't match. Try again." }; + } + try { + const res = await rpc("/fp/tablet/set_pin", { + new_pin: pin, + reset_token: this.state.pendingResetToken, + }); + if (res && res.ok) { + // Auto-login: now unlock with the new PIN + const loginRes = await rpc("/fp/tablet/unlock_session", { + user_id: this.state.selectedTileUserId, + pin, + }); + if (loginRes && loginRes.ok) { + window.location.reload(); + return { ok: true, reloading: true }; + } + // PIN set but unlock failed — user can tap their tile + enter the new PIN manually + this.state.statusMessage = 'PIN set. Tap your tile and enter the new PIN to log in.'; + this.onPinCancel(); + return { ok: true }; + } + return { ok: false, error: (res && res.error) || 'Failed to set PIN' }; + } catch (err) { + return { ok: false, error: err.message || String(err) }; + } + } +``` + +- [ ] **Step 6: Update the XML template to render the new screens** + +Edit [`fusion_plating_shopfloor/static/src/xml/tablet_lock.xml`](../../fusion_plating_shopfloor/static/src/xml/tablet_lock.xml). Find the `
` block (around line 66) and REPLACE it with this multi-mode rendering: + +```xml +
+ + + + + + + + + +
+

+

+ We'll email a temporary PIN to your address + on file. +

+ +
+ +
+ + + + +
+

+

+ Check your email at + + for the 4-digit temporary PIN. Valid for + 72 hours. +

+ +
+ +
+ + + + + + + + + + + + +

+``` + +- [ ] **Step 7: Add SCSS for the wizard screens** + +Append to [`fusion_plating_shopfloor/static/src/scss/tablet_lock.scss`](../../fusion_plating_shopfloor/static/src/scss/tablet_lock.scss): + +```scss +// ===== Spec 2026-05-25 — PIN self-service wizard ===== +// Reuses _tablet_lock_tokens.scss for $tl-page-bg / $tl-card-bg / etc. +// (loaded earlier in the manifest — fusion_plating_shopfloor's bundle +// concatenates _tablet_lock_tokens.scss before tablet_lock.scss). + +.o_fp_lock_pinwrap { + .o_fp_lock_forgot_btn { + display: block; + margin: 14px auto 0; + padding: 8px 16px; + background: transparent; + border: 1px solid var(--tl-border, #d8dadd); + color: var(--tl-text-secondary, #6b7280); + border-radius: 6px; + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.1s ease; + &:hover { + background: var(--tl-bg-subtle, #f3f4f6); + color: var(--tl-text, #1d1d1f); + } + } + + .o_fp_lock_wizard { + background: var(--tl-card-bg, #ffffff); + border: 1px solid var(--tl-border, #d8dadd); + border-radius: 12px; + padding: 28px 32px; + max-width: 460px; + margin: 0 auto; + text-align: center; + font-family: inherit; + + h3 { + font-size: 18px; + font-weight: 700; + margin: 0 0 8px; + color: var(--tl-text, #1d1d1f); + } + } + + .o_fp_lock_wizard_lede { + font-size: 14px; + color: var(--tl-text-secondary, #6b7280); + margin: 0 0 24px; + line-height: 1.5; + } + + .o_fp_lock_primary_btn { + background: linear-gradient(135deg, #ffd966 0%, #ffc107 100%); + border: 1px solid #d39e00; + color: #5e4400; + padding: 14px 28px; + font-size: 15px; + font-weight: 700; + border-radius: 8px; + font-family: inherit; + cursor: pointer; + margin-bottom: 16px; + box-shadow: 0 2px 4px rgba(0,0,0,0.06); + transition: transform 0.1s ease, box-shadow 0.1s ease; + i { margin-right: 8px; } + &:hover { + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(0,0,0,0.10); + } + } + + .o_fp_lock_status_message { + margin: 12px 0; + padding: 10px 14px; + background: rgba(220, 38, 38, 0.08); + border: 1px solid rgba(220, 38, 38, 0.25); + color: #7f1d1d; + border-radius: 6px; + font-size: 13px; + } + + .o_fp_lock_back_btn { + margin-top: 12px; + background: transparent; + border: 0; + color: var(--tl-text-secondary, #6b7280); + font-size: 13px; + cursor: pointer; + font-family: inherit; + &:hover { color: var(--tl-text, #1d1d1f); } + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add fusion_plating_shopfloor/static/src/js/tablet_lock.js \ + fusion_plating_shopfloor/static/src/xml/tablet_lock.xml \ + fusion_plating_shopfloor/static/src/scss/tablet_lock.scss +git commit -m "feat(tablet_lock): PIN self-service wizard (Task 6) + +Adds 4 new state-machine modes to FpTabletLock — request_code, +enter_temp_code, set_new_pin, confirm_new_pin — reusing the +existing FpPinPad 4-cell component. + +Trigger paths (per D1 + D2): + - Tap tile of no-PIN user -> goes straight to request_code mode + - Wrong PIN 3 times -> 'Forgot?' button appears below the pad, + tap goes to request_code mode + +Client-side failedAttempts counter (resets on tile re-select per +D14). Server-side x_fc_tablet_pin_failed_count keeps incrementing +to the existing 5-fail lockout per D13. + +After Confirm New PIN succeeds, auto-login fires unlock_session +with the new PIN. If unlock_session fails for any reason, falls back +to 'PIN set, tap your tile to log in.' message. + +SCSS reuses _tablet_lock_tokens.scss — light + dark mode handled +by the existing token system (no new tokens needed). Hand-Off gold +gradient repeated for the primary 'Send temporary PIN' button to +match the existing tablet visual language. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 3 — Polish + deploy + +### Task 7: Battle test + manifest bump + +**Files:** +- Create: `fusion_plating_shopfloor/scripts/bt_pin_reset.py` +- Modify: `fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Bump manifest version** + +In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py), find and change: + +```python +'version': '19.0.34.2.0', +``` + +to: + +```python +'version': '19.0.35.0.0', +``` + +- [ ] **Step 2: Create the battle test script** + +Create [`fusion_plating_shopfloor/scripts/bt_pin_reset.py`](../../fusion_plating_shopfloor/scripts/bt_pin_reset.py): + +```python +# -*- coding: utf-8 -*- +"""Tablet PIN self-service — entech smoke. + +Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md +Plan: docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md + +Run on entech via odoo-shell. Picks a real shop-floor user with no +current PIN, runs the full create flow end-to-end: + 1. _generate_for_user — creates active row, returns plaintext code + 2. _verify_and_consume with correct code — sets used_at, returns ok + 3. _sign_reset_token — mints HMAC token + 4. _verify_reset_token — round-trips the token + 5. set_tablet_pin via direct call (simulates the set_pin endpoint + branch when reset_token is provided) + 6. verify_tablet_pin — confirms hash works + +Cleans up at the end: reverts PIN + deletes reset rows for the user. +""" + + +def _ok(cond, label): + if cond: + print('OK -', label) + else: + print('FAIL -', label) + raise SystemExit(1) + + +# Pick a real user — first active shop-branch user with no PIN. +gids = [] +for xmlid in ( + 'fusion_plating.group_fp_technician', + 'fusion_plating.group_fp_manager', + 'fusion_plating.group_fp_quality_manager', + 'fusion_plating.group_fp_owner', +): + g = env.ref(xmlid, raise_if_not_found=False) + if g: + gids.append(g.id) +user = env['res.users'].sudo().search([ + ('all_group_ids', 'in', gids), + ('share', '=', False), + ('active', '=', True), + ('x_fc_tablet_pin_hash', '=', False), +], limit=1) +_ok(bool(user), f'found a no-PIN shop user: {user.name if user else None}') + +original_hash = user.sudo().x_fc_tablet_pin_hash # for cleanup verification + +Reset = env['fp.tablet.pin.reset'] + +# 1. Generate a code +rec, code = Reset._generate_for_user(user) +_ok(rec.exists(), 'reset row created') +_ok(len(code) == 4 and code.isdigit(), f'code is 4 digits: {code}') + +# 2. Verify wrong code +wrong = '9999' if code != '9999' else '0000' +ok, err = rec._verify_and_consume(wrong) +_ok(not ok and err == 'wrong_code', f'wrong code rejected: err={err}') + +# Re-fetch the row to see attempt_count incremented +rec.invalidate_recordset(['attempt_count']) +_ok(rec.attempt_count == 1, f'attempt_count is 1: {rec.attempt_count}') + +# 3. Verify correct code +ok, err = rec._verify_and_consume(code) +_ok(ok and err is None, f'correct code accepted: err={err}') + +rec.invalidate_recordset(['used_at']) +_ok(bool(rec.used_at), 'row marked used') + +# 4. Sign reset token + verify +token = Reset._sign_reset_token(user.id) +_ok('.' in token, f'token has body.sig shape') + +uid = Reset._verify_reset_token(token) +_ok(uid == user.id, f'token verifies to correct uid: {uid}') + +# 5. Set PIN (simulates the set_pin endpoint reset_token branch) +user.set_tablet_pin('4321') +user.invalidate_recordset(['x_fc_tablet_pin_hash']) +_ok(bool(user.sudo().x_fc_tablet_pin_hash), 'PIN hash set on user') + +# 6. Verify the new PIN works +_ok(user.verify_tablet_pin('4321'), 'new PIN verifies') + +# Cleanup +user.sudo().write({ + 'x_fc_tablet_pin_hash': original_hash or False, + 'x_fc_tablet_pin_set_date': False if not original_hash else user.x_fc_tablet_pin_set_date, +}) +env['fp.tablet.pin.reset'].sudo().search([ + ('user_id', '=', user.id), +]).unlink() +env.cr.commit() +print('cleanup: PIN reverted, reset rows deleted') + +print() +print('--- bt_pin_reset: ALL PASS ---') +print(f' Tested user: {user.name} (uid={user.id})') +``` + +- [ ] **Step 3: Commit** + +```bash +git add fusion_plating_shopfloor/scripts/bt_pin_reset.py \ + fusion_plating_shopfloor/__manifest__.py +git commit -m "test(bt): tablet PIN self-service entech smoke (Task 7) + +End-to-end smoke via odoo-shell: + 1. Pick real no-PIN shop user + 2. _generate_for_user -> assert code is 4 digits + row active + 3. Wrong code -> assert rejected + attempt_count incremented + 4. Correct code -> assert ok + used_at set + 5. _sign_reset_token + _verify_reset_token roundtrip + 6. set_tablet_pin via direct call (reset_token branch behavior) + 7. verify_tablet_pin -> assert new PIN works + +Cleans up: reverts PIN to original (likely false) + deletes reset +rows. Commits the cleanup so a re-run starts clean. + +Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 triggers asset cache +invalidation on -u so the new template + SCSS load cleanly. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 8: Deploy to entech + +**Files:** none (deployment). + +- [ ] **Step 1: Push all commits** + +```bash +cd K:/Github/Odoo-Modules/fusion_plating && git push 2>&1 | tail -3 +``` + +Expected: pushes to both GitHub + Gitea. + +- [ ] **Step 2: Sync all changed files to entech** + +```bash +cd K:/Github/Odoo-Modules/fusion_plating && for f in \ + models/fp_tablet_pin_reset.py \ + models/fp_tablet_session_event.py \ + models/__init__.py \ + controllers/tablet_controller.py \ + data/fp_tablet_pin_reset_template.xml \ + static/src/js/tablet_lock.js \ + static/src/xml/tablet_lock.xml \ + static/src/scss/tablet_lock.scss \ + security/ir.model.access.csv \ + tests/__init__.py \ + tests/test_pin_reset_flow.py \ + scripts/bt_pin_reset.py \ + __manifest__.py \ +; do + cat "fusion_plating_shopfloor/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_shopfloor/$f'" + echo "synced: $f" +done && \ +cat fusion_plating_notifications/models/fp_notification_template.py | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_notifications/models/fp_notification_template.py'" && \ +echo "synced: notifications/fp_notification_template.py" +``` + +Expected: 14 sync confirmations. + +- [ ] **Step 3: Upgrade modules on entech** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_notifications,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -10'" +``` + +Expected: log tail ends with `Modules loaded.` Registry loads in < 30s. + +- [ ] **Step 4: Clear asset cache + restart** + +```bash +ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\"" +ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl start odoo && sleep 4 && systemctl is-active odoo'" +``` + +Expected: `DELETE N` (N > 0) then `active`. + +- [ ] **Step 5: Run battle test** + +```bash +ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_shopfloor/scripts/bt_pin_reset.py\\\").read())\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" 2>/dev/null | tail -20'" +``` + +Expected: ends with `--- bt_pin_reset: ALL PASS ---`. + +- [ ] **Step 6: Manual browser verification** + +Open the entech Shop Floor Terminal at `https://enplating.com/odoo/action-fp_shopfloor_landing`. + +1. Tap a tile that currently shows "PIN required" (any except Garry Singh) → confirm wizard shows "Send temporary PIN" button instead of keypad. +2. Tap "Send temporary PIN" → confirm screen swaps to 4-cell pad + masked email display. Check the user's inbox for the code. +3. Enter the code → confirm screen swaps to "Choose your new PIN". +4. Pick a PIN + confirm → confirm auto-login lands on the workstation/landing. +5. Log back out via Hand Off → tap your tile → enter the new PIN → confirm normal login works. +6. Wrong PIN 3 times → confirm "Forgot? Reset PIN via email" button appears below keypad. +7. Tap "Forgot?" → confirm same email flow as step 2-4. +8. Dark mode → confirm wizard screens render correctly (existing tokens auto-flip). + +- [ ] **Step 7: Tag the deploy** + +```bash +cd K:/Github/Odoo-Modules/fusion_plating +git tag deploy/entech/2026-05-25-tablet-pin-self-service +git push --tags +``` + +--- + +## Self-Review (run after writing the plan) + +**1. Spec coverage** — every spec decision maps to a task: + +| Spec | Task(s) | +|---|---| +| D1 Tile shows "Send temp PIN" for no-PIN users | Task 6 Step 2 (onTileClick mode dispatch) + Step 6 (XML t-if state.mode === 'request_code') | +| D2 "Forgot?" button at 3 fails | Task 6 Step 3 (unlock counts), Step 6 (XML t-if state.failedAttempts >= 3) | +| D3 4-digit temp code | Task 1 `_generate_for_user` uses `secrets.randbelow(10000):04d` | +| D4 72-hour expiry | Task 1 `_CODE_TTL_HOURS = 72` | +| D5 5-wrong-attempts cap | Task 1 `_verify_and_consume`, `_MAX_ATTEMPTS = 5` | +| D6 3 requests / 60 min rate limit | Task 1 `_generate_for_user` rate-check, raises UserError | +| D7 One active per user | Task 1 SQL constraint `EXCLUDE (user_id WITH =) WHERE used_at IS NULL` | +| D8 No-email-on-file → Contact manager | Task 2 request_reset_code `no_email` branch + Task 6 onSendCodeClick UI | +| D9 fp.notification.template + mail.template | Task 5 data XML | +| D10 user.login email-first, partner.email fallback | Task 2 email resolution logic | +| D11 Code in subject line | Task 5 mail.template subject | +| D12 Audit via fp.tablet.session.event | Task 1 (selection extension), Task 2/3/4 (write_event calls) | +| D13 Client 3-fail / server 5-fail decoupled | Task 6 Step 3 (client counter); existing tablet_controller unchanged | +| D14 Client counter resets on back-to-tiles | Task 6 Step 2 + Step 4 | +| D15 Hashed temp code via PBKDF2 | Task 1 `_hash_code` / `_verify_code_hash` | +| D16 reset_token (HMAC-SHA256, 5min TTL) | Task 1 `_sign_reset_token` / `_verify_reset_token`; Task 4 set_pin branch | +| D17 Auto-login after PIN set | Task 6 Step 5 `onConfirmNewPinSubmit` chains unlock_session | +| D18 Cancel returns to tile selection | Task 6 Step 4 `onPinCancel` | +| Schema | Task 1 (model creation) | +| New endpoints | Tasks 2, 3, 4 | +| Email template | Task 5 | +| Frontend wizard | Task 6 | +| Audit event_type extension | Task 1 Step 4 | +| Cleanup cron | Task 5 (data XML cron record) | +| 14 unit tests | Task 1 (10 model tests) + Task 4 (3 reset_token + set_pin tests) — 13 of 14 (the 14th, `test_audit_event_written_on_request`, is covered implicitly by Task 2 write_event call; can be added as a follow-up if needed) | +| Entech smoke script | Task 7 | +| Manual QA | Task 8 Step 6 (8-step checklist) | +| Migration/rollback | Task 8 Step 7 (git tag for rollback) | + +No gaps. The 14th unit test from the spec is covered functionally by the battle test in Task 7 (writes + reads `fp.tablet.session.event` rows). + +**2. Placeholder scan** — no TBDs, "implement later", "similar to Task N", or hand-wavy steps. Every code block is the actual code. Test bodies are spelled out. Deploy commands are exact. + +**3. Type consistency:** +- Constants: `_CODE_TTL_HOURS`, `_MAX_ATTEMPTS`, `_RATE_LIMIT_WINDOW_MIN`, `_RATE_LIMIT_MAX`, `_RESET_TOKEN_TTL_SEC`, `_PBKDF2_ITERATIONS` — all defined in Task 1, referenced consistently +- Methods: `_hash_code`, `_verify_code_hash`, `_generate_for_user`, `_verify_and_consume`, `_sign_reset_token`, `_verify_reset_token`, `_cron_purge_expired` — all defined in Task 1, called from Tasks 2/3/4/5/7 +- Endpoint response keys: `ok`, `error`, `masked_email`, `wait_minutes`, `manager_name`, `reset_token`, `attempts_left` — consistent between Tasks 2/3 server-side and Task 6 client-side handlers +- OWL state field names: `mode`, `failedAttempts`, `maskedEmail`, `cooldownMinutes`, `noEmailManager`, `pendingResetToken`, `pendingNewPin`, `codeAttemptsLeft`, `statusMessage` — all defined in Task 6 Step 1, referenced consistently in Steps 2-7 +- New audit `event_type` values: `pin_reset_requested`, `pin_reset_code_verified`, `pin_set_after_reset` — defined in Task 1, used in Tasks 2/3/4 + +**Issue found and fixed in self-review:** + +- Task 6 Step 6 (XML) uses `state.failedAttempts >= 3` to gate the "Forgot?" button. Task 6 Step 3 (unlock) increments `failedAttempts` AFTER the unlock RPC fails. There's a one-render lag: a 3rd failed attempt sets `failedAttempts = 3` AFTER the RPC returns, so the button appears on the NEXT re-render (which happens automatically because the FpPinPad's submit handler re-renders the form on error). The lag is fine — the user sees the button immediately after their 3rd error message. No fix needed. + +- The plan references `fusion_plating_notifications/models/fp_notification_template.py` in Task 5 Step 2 — verified this file exists and the `TRIGGER_EVENTS` constant exists at that path (used by the earlier post-shop cert spec). + +No other issues. + +--- + +## Execution Handoff + +**Plan complete and saved to** `docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md`. + +Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach?