POS — Авторизация

Бизнес-спека: как кассир входит на кассе и какие защиты гарантируют, что он залогинится только в свою ТТ.

Основной flow — email + password

Веб-сборка desktop-pos (на test-VPS https://erp-test.nirbi.ru/pos/) и Windows-сборка через Tauri используют email + password как основной способ логина. Формы:

  • Поле «Email»
  • Поле «Пароль»
  • Кнопка «Войти»

Учётные данные сотрудника создаются в erp-admin → «Сотрудники» → «Создать»:

  • email + password (bcrypt-хэш в employees.password_hash)
  • назначение на (role, store_ids) через employee_role_stores

Цепочка вызовов

sequenceDiagram
    participant Cashier
    participant LoginScreen
    participant POS_BFF as POS BFF
    participant Auth as Auth Service
    participant User as User Service

    Cashier->>LoginScreen: email + пароль
    LoginScreen->>POS_BFF: POST /api/v1/pos/auth/login<br/>{email, password, store_id}
    POS_BFF->>Auth: POST /api/v1/auth/login {email, password}
    Auth->>User: validateCredentials
    User-->>Auth: Employee + roles + permissions
    Auth-->>POS_BFF: { access_token, refresh_token,<br/>user: { permissions, scope.store_ids, ... } }
    POS_BFF->>POS_BFF: проверки<br/>(см. ниже)
    POS_BFF-->>LoginScreen: { access_token, user.active_store_id }
    LoginScreen->>LoginScreen: навигация на /shift/open или /main

Защиты (POS BFF /api/v1/pos/auth/login)

ПроверкаУсловиеОшибка
Базовая валидацияemail, password, store_id обязательны400 VALIDATION_ERROR
Учётные данныеauth-service /login вернул 200401 (исходный код от auth-service)
POS accesspermissions содержит pos.access403 POS_ACCESS_DENIED
Привязка к ТТstore_iduser.scope.store_ids (для не-владельцев)403 STORE_NOT_ASSIGNED

Широкий scope владельцев

ScopeService сериализует enum в snake_case: all_franchise (владелец франшизы) / legal_entity_ids (владелец партнёра) / store_ids (обычный сотрудник). Для первых двух типов проверка store_ids.includes(storeId) пропускается — владельцам видны все ТТ их франшизы по правилам Roles §«Три сценария scope сотрудника». Принадлежность ТТ к нужной franchise гарантируется auth-service /login (он не отдаёт токен для чужой franchise).

Привязка устройства к ТТ

store_id фиксируется на этапе device registration (RegistrationScreen wizard) и сохраняется в localStorage под ключом pos.store_id. На каждый логин фронт передаёт его в теле запроса.

В dev-сборке (Dockerfile) есть fallback VITE_DEV_STORE_ID=… — конкретная ТТ test-VPS. В прод-сборке env должна быть пуста: касса обязана пройти wizard перед первым логином.

Ростер кассиров и PIN-логин (POS Desktop)

В Tauri-сборке POS Desktop логин двухслойный — экономит время на пересменке и на повторном входе после logout.

Поведение:

  1. Первый раз на этой кассеLoginScreen (email + password). После успеха кассир добавляется в локальный «ростер» устройства (localStorage["pos:cashier-roster"]).
  2. Каждый следующий раз (после logout / закрытия смены / перезапуска приложения):
    • Ростер пуст → LoginScreen (как при первом запуске)
    • Ростер = 1 кассир → CashierPinScreen сразу с его именем (single-shortcut)
    • Ростер > 1 → CashierRosterScreen с плитками → тап → CashierPinScreen
  3. На CashierPinScreen ввод 4-значного PIN → POST /api/v1/pos/auth/pin с employee_id (известен из ростера). Сервер валидирует.

Хранилище (localStorage["pos:cashier-roster"]):

[{ "id": "uuid", "email": "...", "first_name": "...", "last_name": "...",
   "added_at": "ISO", "last_login_at": "ISO" }]

UX-детали:

  • ≤8 кассиров — крупные плитки в гриде 2×N. >8 — скролл-список с поиском по имени/email.
  • Удаление кассира с устройства: кнопка «Изменить» в шапке → плитки трясутся, появляется «×» → confirm → запись удалена. Защита от случайного клика — двухстадийная.
  • На CashierPinScreen всегда видна ссылка «Войти под другим →» — единственный путь добавить второго кассира из single-roster случая.

Семантика logout/закрытия смены (handover):

  • Logout — дроп сессии, смена не закрывается, ростер не трогается. Другой кассир логинится из ростера и продолжает ту же смену.
  • Закрытие смены — close shift на бэке + дроп сессии. Ростер цел. Z-отчёт привязан к кассиру, открывшему смену.

Инвалидация записи:

  • INVALID_PIN от сервера — PIN очищается, точки трясутся, retry. Запись остаётся.
  • Любая другая 401/403 (POS_ACCESS_DENIED, STORE_NOT_IN_FRANCHISE, DEVICE_REVOKED) — запись автоматически удаляется из ростера + сообщение → ростер/login.
  • Смена пароля не инвалидирует PIN — это by design (PIN и password независимы в user-service).

Файлы реализации (POS Desktop):

  • apps/desktop/src/lib/cashierRoster.ts — getRoster / upsertCashier / removeCashier / findCashier
  • apps/desktop/src/screens/CashierRosterScreen.tsx — список + edit-mode
  • apps/desktop/src/screens/CashierPinScreen.tsx — pin-пад
  • apps/desktop/src/routes/routeAfterAuthDrop.ts — единый хелпер маршрутизации после drop сессии (используется в AppShell.handleLogout, ShiftCloseScreen, CashierPinScreen после revoked, ProtectedRoute)
  • Изменения в authStore.ts (upsert при login), App.tsx (новые роуты), LoginScreen.tsx (ссылка «← К списку»)

PIN-flow в web-сборке

В web-сборке desktop-pos (за nginx) — только email+password. Ростер реализован только в Tauri-сборке POS Desktop, где касса физически привязана к одному устройству. Endpoint /api/v1/pos/auth/pin живёт в erp-pos/bff/src/routes/auth.ts и вызывает auth-service /api/v1/auth/pin-login. Защиты те же что и у /login.

Мульти-сторовые сотрудники

Сотрудник, назначенный на несколько ТТ (employee_role_stores с разными store_id), может войти только на ту кассу, store_id которой совпадает с его привязкой. UI выбора ТТ при логине не реализован — пользователь должен зайти с правильно зарегистрированной кассы.

Расширение позже

Если возникнет потребность — LoginScreen можно расширить <select>-полем «Торговая точка», заполняемым из user.scope.store_ids после успешного auth. На данный момент это вне scope.

Refresh + logout

EndpointНазначение
POST /api/v1/pos/auth/refreshОбновление access_token по refresh_token (стандартный JWT-cycle)
POST /api/v1/pos/auth/logout204, инвалидация на стороне auth-service не реализована — токен умирает по expires_in
GET /api/v1/pos/auth/meТекущий user + permissions; используется для guard-роутов и обновления локального стейта

Out of scope

  • Forgot password / reset на стороне POS — пароль выставляется/перевыставляется администратором через erp-admin (PUT /api/v1/admin/employees/{id} с новым password)
  • MFA / биометрия — не нужны на POS
  • AFK lock — idle timeout с PIN-only без ростера (отдельная задача, использует те же PIN-компоненты)
  • StoreSelector при логине — описан в разделе «Мульти-сторовые сотрудники»

Ссылки