diff --git a/fusion_plating/fusion_plating_shopfloor/models/__init__.py b/fusion_plating/fusion_plating_shopfloor/models/__init__.py index e7cd4b46..9f018fd6 100644 --- a/fusion_plating/fusion_plating_shopfloor/models/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/models/__init__.py @@ -8,3 +8,4 @@ from . import fp_bake_window from . import fp_first_piece_gate from . import fp_operator_queue from . import fp_tank +from . import res_users diff --git a/fusion_plating/fusion_plating_shopfloor/models/res_users.py b/fusion_plating/fusion_plating_shopfloor/models/res_users.py new file mode 100644 index 00000000..8363bd06 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/models/res_users.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# Part of the Fusion Plating product family. +"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign). + +Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints +and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256 +hash; never plaintext. +""" +import hashlib +import secrets + +from odoo import _, fields, models +from odoo.exceptions import UserError + +# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe +# against brute-force even if the DB leaks. +_PBKDF2_ITERATIONS = 200_000 + + +class ResUsers(models.Model): + _inherit = 'res.users' + + x_fc_tablet_pin_hash = fields.Char( + string='Tablet PIN (hashed)', + groups='fusion_plating.group_fusion_plating_manager', + help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet ' + 'PIN. Format: $. Never readable to ' + 'non-managers; never logged.', + ) + x_fc_tablet_pin_set_date = fields.Datetime( + string='Tablet PIN Set Date', + help='When the current PIN was last set or changed.', + ) + x_fc_tablet_pin_failed_count = fields.Integer( + string='Failed PIN Attempts', + default=0, + help='Sequential failed unlock attempts since the last success. ' + 'Resets to 0 on a correct PIN.', + ) + x_fc_tablet_locked_until = fields.Datetime( + string='Tablet Lockout Until', + help='Wall-clock time at which the per-user lockout expires. ' + 'Null when not locked. Set after the configured fail ' + 'threshold (default 5) is reached.', + ) + + @staticmethod + def _hash_tablet_pin(pin, salt=None): + """Hash `pin` with optional salt. Returns "salt_hex$digest_hex".""" + if salt is None: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + 'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return f"{salt.hex()}${digest.hex()}" + + @staticmethod + def _verify_tablet_pin_hash(pin, stored): + """Constant-time verify of `pin` 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', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS, + ) + return secrets.compare_digest(digest.hex(), expected_hex) + + def set_tablet_pin(self, pin): + """Set or change this user's tablet PIN. Requires sudo OR self. + + Caller is responsible for verifying the OLD pin separately if a + hash already exists — this method just writes the new one. + """ + self.ensure_one() + if not pin or not pin.isdigit() or len(pin) != 4: + raise UserError(_('Tablet PIN must be exactly 4 digits.')) + self.sudo().write({ + 'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin), + 'x_fc_tablet_pin_set_date': fields.Datetime.now(), + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + return True + + def verify_tablet_pin(self, pin): + """Return True if `pin` matches this user's stored hash.""" + self.ensure_one() + if not pin: + return False + # sudo: even non-manager callers may need to verify their OWN PIN. + # The hash field has manager-only read; sudo bypasses that. + return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash) + + def clear_tablet_pin(self): + """Manager-side reset. Clears hash so target must set a new PIN. + Posts to chatter for audit. + """ + self.ensure_one() + manager_name = self.env.user.name + self.sudo().write({ + 'x_fc_tablet_pin_hash': False, + 'x_fc_tablet_pin_set_date': False, + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + self.message_post( + body=_('Tablet PIN reset by %s. User must set a new PIN ' + 'on next unlock attempt.') % manager_name, + ) + return True + + def action_open_tablet_pin_setup(self): + """Trigger the FpPinSetup OWL modal from the Preferences form. + The Phase 6.2 OWL component intercepts this action tag. + """ + self.ensure_one() + return { + 'type': 'ir.actions.client', + 'tag': 'fp_tablet_pin_setup', + 'name': 'Set Tablet PIN', + 'target': 'new', + } diff --git a/fusion_plating/fusion_plating_shopfloor/tests/__init__.py b/fusion_plating/fusion_plating_shopfloor/tests/__init__.py index 5f0627ad..b4407567 100644 --- a/fusion_plating/fusion_plating_shopfloor/tests/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/tests/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import test_workspace_controller from . import test_landing_kanban +from . import test_tablet_pin diff --git a/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py new file mode 100644 index 00000000..73888f32 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. — License OPL-1 +"""Phase 6 — Tablet PIN gate tests.""" +import json + +from odoo.tests.common import HttpCase, TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin') +class TestTabletPinHash(TransactionCase): + """P6.1.1 — model fields + hash helpers + set/verify/clear methods.""" + + def setUp(self): + super().setUp() + self.user = self.env['res.users'].create({ + 'name': 'Pin Test', + 'login': 'pintest@example.com', + }) + + def test_set_pin_stores_salted_hash(self): + self.user.sudo().set_tablet_pin('1234') + stored = self.user.sudo().x_fc_tablet_pin_hash + self.assertTrue(stored) + self.assertIn('$', stored, 'hash must include salt separator') + # Hash is non-deterministic — setting same PIN twice gives different stored values + self.user.sudo().set_tablet_pin('1234') + self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash) + + def test_verify_correct_pin(self): + self.user.sudo().set_tablet_pin('1234') + self.assertTrue(self.user.sudo().verify_tablet_pin('1234')) + + def test_verify_wrong_pin(self): + self.user.sudo().set_tablet_pin('1234') + self.assertFalse(self.user.sudo().verify_tablet_pin('0000')) + + def test_verify_pin_no_hash_set(self): + self.assertFalse(self.user.sudo().verify_tablet_pin('1234')) + + def test_set_pin_rejects_invalid_format(self): + from odoo.exceptions import UserError + for bad in ('123', '12345', 'abcd', '', None): + with self.assertRaises(UserError): + self.user.sudo().set_tablet_pin(bad) + + def test_clear_pin_wipes_hash_and_posts_chatter(self): + self.user.sudo().set_tablet_pin('1234') + self.user.clear_tablet_pin() + self.user.invalidate_recordset(['x_fc_tablet_pin_hash']) + self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash) + self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date) + # Check chatter + last_msg = self.user.message_ids[:1] + self.assertIn('reset by', (last_msg.body or '').lower())