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>
1873 lines
76 KiB
Markdown
1873 lines
76 KiB
Markdown
# 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: '
|
|
'<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`](../../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) <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`](../../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: '<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**
|
|
|
|
```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) <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`:
|
|
|
|
```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: '<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) <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:
|
|
|
|
```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) <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`](../../fusion_plating_shopfloor/data/fp_tablet_pin_reset_template.xml):
|
|
|
|
```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):
|
|
|
|
```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) <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`](../../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 `<div t-else="" class="o_fp_lock_pinwrap">` block (around line 66) and REPLACE it with this multi-mode rendering:
|
|
|
|
```xml
|
|
<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`](../../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) <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`](../../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) <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### 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?
|