Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-25-tablet-pin-self-service-plan.md
gsinghpal 33fff5acba docs(plan): tablet PIN self-service implementation plan
8 tasks across 3 phases:
  Phase 1 — Backend foundation (Tasks 1-5)
    T1: New model fp.tablet.pin.reset + ACL + event_type extension
        + 10 model tests (hash helpers, lifecycle, rate limit,
        attempt cap, expired, token sign roundtrip + tamper checks)
    T2: /fp/tablet/request_reset_code endpoint
    T3: /fp/tablet/verify_reset_code endpoint
    T4: /fp/tablet/set_pin accepts reset_token alternative
        (+ 3 more tests)
    T5: mail.template + fp.notification.template + cleanup cron
  Phase 2 — Frontend (Task 6)
    T6: FpTabletLock wizard — 4 new state-machine modes
        (request_code, enter_temp_code, set_new_pin, confirm_new_pin),
        reuses FpPinPad 4-cell component, auto-login chain,
        client-side 3-fail counter for 'Forgot?' button
  Phase 3 — Deploy (Tasks 7-8)
    T7: Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 + bt_pin_reset
        entech smoke
    T8: Sync 14 files + upgrade + asset bust + smoke + 8-step
        manual QA + tag deploy

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:42:04 -04:00

76 KiB

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

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

# -*- 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: '
             '<salt_hex>$<digest_hex>. 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, find the from . import models block (or wherever model imports live). Add:

from . import models  # existing

Then in fusion_plating_shopfloor/models/__init__.py:

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, append one line at the end:

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, find the event_type = fields.Selection(...) block (line 23). Modify by adding 3 new values:

    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:

# -*- 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, append:

from . import test_pin_reset_flow
  • Step 7: Commit
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) <noreply@anthropic.com>"

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, find the section divider before # /fp/tablet/unlock_session (around line 145). Add a new endpoint right BEFORE that divider:

    # ======================================================================
    # /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: '<owner>'}
          {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
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) <noreply@anthropic.com>"

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:

    # ======================================================================
    # /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: '<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
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) <noreply@anthropic.com>"

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:

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

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
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) <noreply@anthropic.com>"

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:

<?xml version="1.0" encoding="utf-8"?>
<!--
    Copyright 2026 Nexa Systems Inc.
    License OPL-1 (Odoo Proprietary License v1.0)

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

    Email template + notification-template wrapper + cleanup cron for
    the tablet PIN self-service reset flow.

    Per CLAUDE.md Rule 25 the mail.template references ONLY core
    res.users fields (object.name, object.email, object.login,
    object.company_id). The 4-digit code is passed in via context
    (ctx.code) when /fp/tablet/request_reset_code calls send_mail.
-->
<odoo noupdate="1">

    <!-- ===== Mail template ============================================ -->
    <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.get('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.get('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>

    <!-- ===== fp.notification.template wrapper ========================= -->
    <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>

    <!-- ===== Cleanup cron ============================================ -->
    <record id="cron_purge_expired_pin_resets" model="ir.cron">
        <field name="name">Fusion Plating: Purge expired tablet PIN reset codes</field>
        <field name="model_id" ref="model_fp_tablet_pin_reset"/>
        <field name="state">code</field>
        <field name="code">model._cron_purge_expired()</field>
        <field name="interval_number">1</field>
        <field name="interval_type">days</field>
        <field name="active" eval="True"/>
    </record>

</odoo>
  • 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):

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, find the 'data': [ list and append:

'data/fp_tablet_pin_reset_template.xml',
  • Step 4: Commit
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) <noreply@anthropic.com>"

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. Find the setup() method's this.state = useState({ block (around line 41). Add new fields:

        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:

    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:

    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:

    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:

    // ===== 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. Find the <div t-else="" class="o_fp_lock_pinwrap"> block (around line 66) and REPLACE it with this multi-mode rendering:

                <div t-else="" class="o_fp_lock_pinwrap">

                    <!-- Mode: 'pin' — default keypad for users with PIN -->
                    <t t-if="state.mode === 'pin'">
                        <FpPinPad onSubmit.bind="unlock"
                                  title="_selectedTileName()"
                                  subtitle="'Enter your 4-digit PIN'"
                                  onCancel.bind="onPinCancel"/>
                        <button t-if="state.failedAttempts >= 3"
                                class="o_fp_lock_forgot_btn"
                                t-on-click="onForgotPinClick">
                            Forgot? Reset PIN via email
                        </button>
                    </t>

                    <!-- Mode: 'request_code' — Send Temp PIN screen -->
                    <t t-elif="state.mode === 'request_code'">
                        <div class="o_fp_lock_wizard">
                            <h3 t-esc="_selectedTileName()"/>
                            <p class="o_fp_lock_wizard_lede">
                                We'll email a temporary PIN to your address
                                on file.
                            </p>
                            <button class="o_fp_lock_primary_btn"
                                    t-on-click="onSendCodeClick">
                                <i class="fa fa-envelope"/> Send temporary PIN
                            </button>
                            <div t-if="state.statusMessage"
                                 class="o_fp_lock_status_message"
                                 t-esc="state.statusMessage"/>
                            <button class="o_fp_lock_back_btn"
                                    t-on-click="onPinCancel">
                                ← Back to tile selection
                            </button>
                        </div>
                    </t>

                    <!-- Mode: 'enter_temp_code' — 4-cell pad for emailed code -->
                    <t t-elif="state.mode === 'enter_temp_code'">
                        <div class="o_fp_lock_wizard">
                            <h3 t-esc="_selectedTileName()"/>
                            <p class="o_fp_lock_wizard_lede">
                                Check your email at
                                <strong t-esc="state.maskedEmail"/>
                                for the 4-digit temporary PIN. Valid for
                                72 hours.
                            </p>
                            <FpPinPad onSubmit.bind="onTempCodeSubmit"
                                      title="''"
                                      subtitle="'Enter temporary PIN from email'"
                                      onCancel.bind="onPinCancel"/>
                            <div t-if="state.statusMessage"
                                 class="o_fp_lock_status_message"
                                 t-esc="state.statusMessage"/>
                            <button class="o_fp_lock_back_btn"
                                    t-on-click="onResendCodeClick">
                                Resend code
                            </button>
                        </div>
                    </t>

                    <!-- Mode: 'set_new_pin' — 4-cell pad to choose new PIN -->
                    <t t-elif="state.mode === 'set_new_pin'">
                        <FpPinPad onSubmit.bind="onNewPinSubmit"
                                  title="_selectedTileName()"
                                  subtitle="'Choose your new 4-digit PIN'"
                                  onCancel.bind="onPinCancel"/>
                    </t>

                    <!-- Mode: 'confirm_new_pin' — 4-cell pad to confirm -->
                    <t t-elif="state.mode === 'confirm_new_pin'">
                        <FpPinPad onSubmit.bind="onConfirmNewPinSubmit"
                                  title="_selectedTileName()"
                                  subtitle="'Confirm your new PIN'"
                                  onCancel.bind="onPinCancel"/>
                    </t>

                </div>
  • Step 7: Add SCSS for the wizard screens

Append to fusion_plating_shopfloor/static/src/scss/tablet_lock.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
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) <noreply@anthropic.com>"

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, find and change:

'version': '19.0.34.2.0',

to:

'version': '19.0.35.0.0',
  • Step 2: Create the battle test script

Create fusion_plating_shopfloor/scripts/bt_pin_reset.py:

# -*- 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
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) <noreply@anthropic.com>"

Task 8: Deploy to entech

Files: none (deployment).

  • Step 1: Push all commits
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
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
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
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
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
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?