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 вернул 200 | 401 (исходный код от auth-service) |
| POS access | permissions содержит pos.access | 403 POS_ACCESS_DENIED |
| Привязка к ТТ | store_id ∈ user.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.
Поведение:
- Первый раз на этой кассе —
LoginScreen(email + password). После успеха кассир добавляется в локальный «ростер» устройства (localStorage["pos:cashier-roster"]). - Каждый следующий раз (после logout / закрытия смены / перезапуска приложения):
- Ростер пуст →
LoginScreen(как при первом запуске) - Ростер = 1 кассир →
CashierPinScreenсразу с его именем (single-shortcut) - Ростер > 1 →
CashierRosterScreenс плитками → тап →CashierPinScreen
- Ростер пуст →
- На
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 / findCashierapps/desktop/src/screens/CashierRosterScreen.tsx— список + edit-modeapps/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/logout | 204, инвалидация на стороне 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 при логине — описан в разделе «Мульти-сторовые сотрудники»