feat: add role switch login and clean production copy
This commit is contained in:
parent
49e60d48c1
commit
b147d632e8
|
|
@ -0,0 +1,83 @@
|
||||||
|
# Role Switch And Production Copy Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Добавить служебный вход по специальному email с выбором роли, убрать demo/PWA-подачу из интерфейса и подготовить клиентскую showcase-ссылку для показа.
|
||||||
|
|
||||||
|
**Architecture:** Обычный OTP flow остается без изменений. Новая служебная ветка определяется на уровне login/auth helper-логики через специальный email `roles@local` и использует локальный fallback-профиль выбранной роли без OTP. UI-copy в логине, overview и клиентской странице упрощается до рабочего нейтрального тона, а PWA explanatory panel убирается из overview.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, existing UI kit, Vitest, local fallback auth, Supabase Auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Role Switch Entry
|
||||||
|
|
||||||
|
### Task 1: Зафиксировать тестами служебный вход по специальному email
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/auth/OtpLoginForm.test.jsx`
|
||||||
|
- Modify: `src/context/AuthContext.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests for service email mode**
|
||||||
|
- [ ] **Step 2: Run targeted auth tests and confirm failure**
|
||||||
|
- [ ] **Step 3: Verify copy no longer references demo mode in production path**
|
||||||
|
|
||||||
|
### Task 2: Реализовать special-email вход без OTP
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/context/AuthContext.jsx`
|
||||||
|
- Modify: `src/pages/LoginPage.jsx`
|
||||||
|
- Modify: `src/components/auth/OtpLoginForm.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add helper constants for `roles@local` and service mode detection**
|
||||||
|
- [ ] **Step 2: Route special-email flow to local role-based sign-in**
|
||||||
|
- [ ] **Step 3: Update login form labels/buttons for service mode**
|
||||||
|
- [ ] **Step 4: Re-run targeted auth tests and confirm pass**
|
||||||
|
|
||||||
|
## Chunk 2: Production Copy Cleanup
|
||||||
|
|
||||||
|
### Task 3: Убрать demo/PWA explanatory copy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/pages/LoginPage.jsx`
|
||||||
|
- Modify: `src/components/auth/OtpLoginForm.jsx`
|
||||||
|
- Modify: `README.md` if needed
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove PWA explanatory panel from overview**
|
||||||
|
- [ ] **Step 2: Replace demo-oriented labels/messages with neutral product copy**
|
||||||
|
- [ ] **Step 3: Re-run affected UI tests**
|
||||||
|
|
||||||
|
## Chunk 3: Client Showcase Link
|
||||||
|
|
||||||
|
### Task 4: Добавить локальную клиентскую showcase-ссылку
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/deliveryInvitationApi.js`
|
||||||
|
- Modify: `src/pages/ClientDeliveryPage.jsx`
|
||||||
|
- Modify: `src/components/client/DeliveryChoiceFlow.test.jsx`
|
||||||
|
- Modify or Create: related test file if needed
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing test for showcase token payload**
|
||||||
|
- [ ] **Step 2: Implement local invitation for showcase token**
|
||||||
|
- [ ] **Step 3: Verify tomorrow/after-tomorrow and half-day slots are shown**
|
||||||
|
- [ ] **Step 4: Re-run client tests and confirm pass**
|
||||||
|
|
||||||
|
## Chunk 4: Final Verification
|
||||||
|
|
||||||
|
### Task 5: Проверка итогового сценария
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Reference: `src/pages/LoginPage.jsx`
|
||||||
|
- Reference: `src/pages/DashboardPage.jsx`
|
||||||
|
- Reference: `src/pages/ClientDeliveryPage.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run targeted test suite**
|
||||||
|
|
||||||
|
Run: `npm test -- src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run broader UI regression checks**
|
||||||
|
|
||||||
|
Run: `npm test -- src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx src/services/orderService.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Role Switch And Production Copy Design
|
||||||
|
|
||||||
|
**Goal:** Убрать из пользовательского интерфейса явную demo/PWA-подачу, добавить служебный сценарий быстрого входа по специальному email с выбором роли и подготовить аккуратную клиентскую ссылку для показа выбора доставки.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Обычный рабочий OTP-вход остается основным сценарием.
|
||||||
|
- При вводе специального email `roles@local` логин переключается в локальный служебный режим:
|
||||||
|
- пользователь выбирает роль `manager`, `logistician` или `driver`;
|
||||||
|
- OTP не требуется;
|
||||||
|
- приложение авторизует пользователя через локальный fallback-профиль для выбранной роли.
|
||||||
|
- Из интерфейса убираются тексты и панели, которые прямо продают demo/PWA-режим.
|
||||||
|
- Для клиентского показа появляется локальный showcase-token с понятными слотами:
|
||||||
|
- завтра / послезавтра;
|
||||||
|
- до обеда / после обеда.
|
||||||
|
|
||||||
|
## UX Decisions
|
||||||
|
|
||||||
|
- Служебный email не скрывается глубоко в коде: он поддерживается формой логина напрямую, чтобы им было удобно пользоваться на встрече.
|
||||||
|
- Тексты в логине и дашборде должны звучать как рабочий продукт, без слов `demo`, `демонстрация`, `PWA-версия`, `offline demo`.
|
||||||
|
- Быстрый вход по роли оформляется как сервисный режим доступа, а не как обучающий блок.
|
||||||
|
- Клиентская showcase-ссылка должна выглядеть как реальная страница подтверждения доставки, а не как технический мок.
|
||||||
|
|
||||||
|
## Data Approach
|
||||||
|
|
||||||
|
- Для служебного role-switch входа используются уже существующие локальные профили ролей.
|
||||||
|
- Для showcase-клиента используется локально собранный invitation payload, без вызова Supabase.
|
||||||
|
- Боевой Supabase flow не меняется и продолжает обслуживать обычные invitation token.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Тесты на логин покрывают:
|
||||||
|
- обычный OTP-copy без demo-текста;
|
||||||
|
- служебный режим по `roles@local`;
|
||||||
|
- выбор роли без OTP.
|
||||||
|
- Тесты client flow покрывают showcase invitation.
|
||||||
|
- После правок прогоняются таргетированные тесты UI и auth.
|
||||||
|
|
@ -15,10 +15,16 @@ export const OtpLoginForm = ({
|
||||||
isOtpSent,
|
isOtpSent,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDemoMode,
|
isDemoMode,
|
||||||
|
isRoleSwitchMode,
|
||||||
onRequestOtp,
|
onRequestOtp,
|
||||||
onVerifyOtp,
|
onVerifyOtp,
|
||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isServiceAccessMode =
|
||||||
|
Boolean(isRoleSwitchMode) || String(email || "").trim().toLowerCase() === "roles@local";
|
||||||
|
const showsLocalRolePicker = !isOtpSent && (isServiceAccessMode || isDemoMode);
|
||||||
|
const submitLabel = isServiceAccessMode ? "Войти без кода" : "Отправить код";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="w-full max-w-md p-5 sm:p-8">
|
<Panel className="w-full max-w-md p-5 sm:p-8">
|
||||||
<div className="mb-6 space-y-2 sm:mb-8">
|
<div className="mb-6 space-y-2 sm:mb-8">
|
||||||
|
|
@ -29,8 +35,8 @@ export const OtpLoginForm = ({
|
||||||
Вход по email и коду
|
Вход по email и коду
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
Введите email, и код придет на почту. Для быстрого доступа к рабочим кабинетам можно
|
||||||
записью в системе, а не выбором роли.
|
использовать служебный адрес и выбрать роль вручную.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -49,14 +55,14 @@ export const OtpLoginForm = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDemoMode && !isOtpSent ? (
|
{showsLocalRolePicker ? (
|
||||||
<div className="space-y-2 rounded-3xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
<div className="space-y-2 rounded-3xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
|
||||||
Демо-режим активен
|
{isServiceAccessMode ? "Служебный вход" : "Локальный вход"}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
||||||
Роль для демо-режима
|
Роль для быстрого входа
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
id="roleHint"
|
id="roleHint"
|
||||||
|
|
@ -71,7 +77,9 @@ export const OtpLoginForm = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
Для демонстрации используется единый адрес входа и код 000000.
|
{isServiceAccessMode
|
||||||
|
? "Выберите кабинет и войдите сразу без подтверждения кода."
|
||||||
|
: "Локальный вход использует единый адрес и код 000000."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -101,7 +109,7 @@ export const OtpLoginForm = ({
|
||||||
|
|
||||||
{!isOtpSent ? (
|
{!isOtpSent ? (
|
||||||
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
||||||
{isLoading ? "Отправка..." : "Отправить код"}
|
{isLoading ? "Проверяем..." : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
||||||
|
|
@ -113,13 +121,15 @@ export const OtpLoginForm = ({
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"mt-6 rounded-3xl p-4 text-sm",
|
"mt-6 rounded-3xl p-4 text-sm",
|
||||||
isDemoMode
|
showsLocalRolePicker
|
||||||
? "border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text)]"
|
? "border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text)]"
|
||||||
: "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
|
: "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{isDemoMode
|
{isServiceAccessMode
|
||||||
? "Демо-режим активен: выберите роль для подстановки данных и используйте код 000000."
|
? "Служебный вход открывает кабинеты менеджера, логиста и водителя без ожидания кода."
|
||||||
|
: isDemoMode
|
||||||
|
? "Локальный режим позволяет открыть интерфейс и проверить структуру кабинетов."
|
||||||
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
|
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,19 @@ describe("OtpLoginForm", () => {
|
||||||
|
|
||||||
expect(markup).toContain("введите email");
|
expect(markup).toContain("введите email");
|
||||||
expect(markup).toContain("доступ определяется учетной записью");
|
expect(markup).toContain("доступ определяется учетной записью");
|
||||||
expect(markup).not.toContain("роль для демо-режима");
|
expect(markup).not.toContain("роль для быстрого входа");
|
||||||
|
expect(markup).not.toContain("демо-режим");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the demo hint visible only in demo mode", () => {
|
it("shows role selection for the special access email without demo wording", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OtpLoginForm {...baseProps} isDemoMode={true} />,
|
<OtpLoginForm {...baseProps} email="roles@local" />,
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
|
|
||||||
expect(markup).toContain("демо-режим активен");
|
expect(markup).toContain("служебный вход");
|
||||||
expect(markup).toContain("роль для демо-режима");
|
expect(markup).toContain("роль для быстрого входа");
|
||||||
|
expect(markup).toContain("войти без кода");
|
||||||
|
expect(markup).not.toContain("демо-режим");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tells operators to check inbox and spam after OTP is sent", () => {
|
it("tells operators to check inbox and spam after OTP is sent", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
online: { label: "Онлайн", tone: "accent" },
|
|
||||||
offline: { label: "Офлайн", tone: "warning" },
|
|
||||||
installed: { label: "Установлено", tone: "accent" },
|
|
||||||
browser: { label: "В браузере", tone: "neutral" },
|
|
||||||
ready: { label: "Офлайн готов", tone: "accent" },
|
|
||||||
pending: { label: "Кешируется", tone: "neutral" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PwaDemoPanel = ({
|
|
||||||
isOnline,
|
|
||||||
isInstallAvailable,
|
|
||||||
isInstalled,
|
|
||||||
isOfflineReady,
|
|
||||||
onInstall,
|
|
||||||
}) => {
|
|
||||||
const networkBadge = isOnline ? statusConfig.online : statusConfig.offline;
|
|
||||||
const installBadge = isInstalled ? statusConfig.installed : statusConfig.browser;
|
|
||||||
const offlineBadge = isOfflineReady ? statusConfig.ready : statusConfig.pending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel className="p-6">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
|
||||||
Демо и PWA
|
|
||||||
</p>
|
|
||||||
<h3 className="text-xl font-semibold">Что это и зачем для демонстрации</h3>
|
|
||||||
<p className="max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
PWA превращает веб-приложение в устанавливаемый рабочий экран: его можно открыть как
|
|
||||||
отдельное приложение и использовать для показа сценариев после первого запуска.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInstallAvailable ? (
|
|
||||||
<Button variant="secondary" onClick={onInstall}>
|
|
||||||
Установить приложение
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
|
||||||
<Badge tone={networkBadge.tone}>{networkBadge.label}</Badge>
|
|
||||||
<Badge tone={installBadge.tone}>{installBadge.label}</Badge>
|
|
||||||
<Badge tone={offlineBadge.tone}>{offlineBadge.label}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
||||||
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
|
||||||
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
|
||||||
Что это
|
|
||||||
</h4>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Это PWA-версия панели: её можно установить на ноутбук или телефон и запускать без
|
|
||||||
адресной строки браузера.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
|
||||||
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
|
||||||
Зачем для демо
|
|
||||||
</h4>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
После первого запуска оболочка кешируется, поэтому дашборд и демо-данные можно
|
|
||||||
показать даже без интернета.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-accent-soft)] p-5">
|
|
||||||
<p className="text-sm font-medium">Как работает офлайн-демо</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Демо-данные доступны локально: роли, вход по коду `000000`, заказы, статусы и обзор
|
|
||||||
дашборда продолжают работать после первого запуска.
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Интеграции и рабочая база требуют подключения, поэтому Supabase и боевые сценарии
|
|
||||||
остаются сетевыми.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { PwaDemoPanel } from "./PwaDemoPanel";
|
|
||||||
|
|
||||||
describe("PwaDemoPanel", () => {
|
|
||||||
it("renders Russian explanation for PWA demo mode", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<PwaDemoPanel
|
|
||||||
isInstallAvailable={true}
|
|
||||||
isInstalled={false}
|
|
||||||
isOfflineReady={true}
|
|
||||||
isOnline={true}
|
|
||||||
onInstall={() => {}}
|
|
||||||
/>,
|
|
||||||
).toLowerCase();
|
|
||||||
|
|
||||||
expect(markup).toContain("что это");
|
|
||||||
expect(markup).toContain("pwa");
|
|
||||||
expect(markup).toContain("после первого запуска");
|
|
||||||
expect(markup).toContain("интеграции и рабочая база требуют подключения");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows install action only when installation is available", () => {
|
|
||||||
const availableMarkup = renderToStaticMarkup(
|
|
||||||
<PwaDemoPanel
|
|
||||||
isInstallAvailable={true}
|
|
||||||
isInstalled={false}
|
|
||||||
isOfflineReady={false}
|
|
||||||
isOnline={true}
|
|
||||||
onInstall={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const installedMarkup = renderToStaticMarkup(
|
|
||||||
<PwaDemoPanel
|
|
||||||
isInstallAvailable={false}
|
|
||||||
isInstalled={true}
|
|
||||||
isOfflineReady={true}
|
|
||||||
isOnline={true}
|
|
||||||
onInstall={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(availableMarkup).toContain("Установить приложение");
|
|
||||||
expect(installedMarkup).not.toContain("Установить приложение");
|
|
||||||
expect(installedMarkup).toContain("Установлено");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows offline badge when browser is offline", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<PwaDemoPanel
|
|
||||||
isInstallAvailable={false}
|
|
||||||
isInstalled={false}
|
|
||||||
isOfflineReady={true}
|
|
||||||
isOnline={false}
|
|
||||||
onInstall={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(markup).toContain("Офлайн");
|
|
||||||
expect(markup).toContain("Демо-данные доступны локально");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
import { safeSupabaseCall } from "../services/safeSupabaseCall";
|
import { safeSupabaseCall } from "../services/safeSupabaseCall";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
const STORAGE_KEY = "construction-auth-demo-user";
|
const STORAGE_KEY = "construction-auth-local-user";
|
||||||
export const DEMO_LOGIN_EMAIL = "demo@local";
|
export const DEMO_LOGIN_EMAIL = "local@local";
|
||||||
|
export const ROLE_SWITCH_ENTRY_EMAIL = "roles@local";
|
||||||
export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору.";
|
export const MISSING_PROFILE_ERROR = "Профиль пользователя не найден. Обратитесь к администратору.";
|
||||||
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
||||||
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
||||||
|
|
@ -43,6 +44,9 @@ export const resolveDemoUser = (email, roleHint) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isRoleSwitchEntryEmail = (email) =>
|
||||||
|
String(email || "").trim().toLowerCase() === ROLE_SWITCH_ENTRY_EMAIL;
|
||||||
|
|
||||||
export const resolveLoginEmail = (isDemoMode, email) =>
|
export const resolveLoginEmail = (isDemoMode, email) =>
|
||||||
isDemoMode ? DEMO_LOGIN_EMAIL : email;
|
isDemoMode ? DEMO_LOGIN_EMAIL : email;
|
||||||
|
|
||||||
|
|
@ -171,7 +175,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otp !== "000000") {
|
if (otp !== "000000") {
|
||||||
throw new Error("Для demo-режима используйте код 000000");
|
throw new Error("Для локального входа используйте код 000000");
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
||||||
|
|
@ -197,6 +201,15 @@ export const AuthProvider = ({ children }) => {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const signInWithRole = async (roleHint) => {
|
||||||
|
const localUser = resolveDemoUser("", roleHint);
|
||||||
|
setUser(localUser);
|
||||||
|
setPendingEmail(ROLE_SWITCH_ENTRY_EMAIL);
|
||||||
|
setIsOtpSent(false);
|
||||||
|
setAuthError("");
|
||||||
|
return { success: true, user: localUser };
|
||||||
|
};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
|
|
@ -206,6 +219,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
isDemoMode: !hasSupabaseConfig,
|
isDemoMode: !hasSupabaseConfig,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
|
signInWithRole,
|
||||||
signOut,
|
signOut,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEMO_LOGIN_EMAIL,
|
DEMO_LOGIN_EMAIL,
|
||||||
|
ROLE_SWITCH_ENTRY_EMAIL,
|
||||||
UNKNOWN_EMAIL_ERROR,
|
UNKNOWN_EMAIL_ERROR,
|
||||||
buildOtpRequestPayload,
|
buildOtpRequestPayload,
|
||||||
|
isRoleSwitchEntryEmail,
|
||||||
mapProfileToAuthUser,
|
mapProfileToAuthUser,
|
||||||
normalizeOtpError,
|
normalizeOtpError,
|
||||||
resolveDemoUser,
|
resolveDemoUser,
|
||||||
|
|
@ -11,17 +13,17 @@ import {
|
||||||
|
|
||||||
describe("resolveDemoUser", () => {
|
describe("resolveDemoUser", () => {
|
||||||
it("prioritizes the selected demo role over a matching email account", () => {
|
it("prioritizes the selected demo role over a matching email account", () => {
|
||||||
const user = resolveDemoUser("manager@demo.local", "driver");
|
const user = resolveDemoUser("manager@local", "driver");
|
||||||
|
|
||||||
expect(user.role).toBe("driver");
|
expect(user.role).toBe("driver");
|
||||||
expect(user.email).toBe("driver@demo.local");
|
expect(user.email).toBe("driver@local");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveLoginEmail", () => {
|
describe("resolveLoginEmail", () => {
|
||||||
it("forces a single shared email in demo mode", () => {
|
it("forces a single shared email in demo mode", () => {
|
||||||
expect(resolveLoginEmail(true, "manager@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
expect(resolveLoginEmail(true, "manager@local")).toBe(DEMO_LOGIN_EMAIL);
|
||||||
expect(resolveLoginEmail(true, "driver@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
expect(resolveLoginEmail(true, "driver@local")).toBe(DEMO_LOGIN_EMAIL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the entered email outside demo mode", () => {
|
it("keeps the entered email outside demo mode", () => {
|
||||||
|
|
@ -29,6 +31,16 @@ describe("resolveLoginEmail", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isRoleSwitchEntryEmail", () => {
|
||||||
|
it("recognizes the special service email regardless of case and spacing", () => {
|
||||||
|
expect(isRoleSwitchEntryEmail(` ${ROLE_SWITCH_ENTRY_EMAIL.toUpperCase()} `)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores regular emails", () => {
|
||||||
|
expect(isRoleSwitchEntryEmail("user@company.ru")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildOtpRequestPayload", () => {
|
describe("buildOtpRequestPayload", () => {
|
||||||
it("disables automatic user creation during OTP sign-in", () => {
|
it("disables automatic user creation during OTP sign-in", () => {
|
||||||
expect(buildOtpRequestPayload("user@company.ru")).toEqual({
|
expect(buildOtpRequestPayload("user@company.ru")).toEqual({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export const demoUsers = [
|
export const demoUsers = [
|
||||||
{
|
{
|
||||||
id: "u-manager",
|
id: "u-manager",
|
||||||
email: "manager@demo.local",
|
email: "manager@local",
|
||||||
name: "Анна Мельник",
|
name: "Анна Мельник",
|
||||||
phone: "+7 978 300-10-01",
|
phone: "+7 978 300-10-01",
|
||||||
role: "manager",
|
role: "manager",
|
||||||
|
|
@ -14,7 +14,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "u-production",
|
id: "u-production",
|
||||||
email: "production@demo.local",
|
email: "production@local",
|
||||||
name: "Илья Корнеев",
|
name: "Илья Корнеев",
|
||||||
phone: "+7 978 300-10-02",
|
phone: "+7 978 300-10-02",
|
||||||
role: "production_lead",
|
role: "production_lead",
|
||||||
|
|
@ -27,7 +27,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "u-logistics",
|
id: "u-logistics",
|
||||||
email: "logistics@demo.local",
|
email: "logistics@local",
|
||||||
name: "Ольга Синицына",
|
name: "Ольга Синицына",
|
||||||
phone: "+7 978 300-10-03",
|
phone: "+7 978 300-10-03",
|
||||||
role: "logistician",
|
role: "logistician",
|
||||||
|
|
@ -40,7 +40,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "u-logistics-2",
|
id: "u-logistics-2",
|
||||||
email: "route@demo.local",
|
email: "route@local",
|
||||||
name: "Павел Миронов",
|
name: "Павел Миронов",
|
||||||
phone: "+7 978 300-10-04",
|
phone: "+7 978 300-10-04",
|
||||||
role: "logistician",
|
role: "logistician",
|
||||||
|
|
@ -53,7 +53,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "u-driver",
|
id: "u-driver",
|
||||||
email: "driver@demo.local",
|
email: "driver@local",
|
||||||
name: "Артём Громов",
|
name: "Артём Громов",
|
||||||
phone: "+7 978 300-10-06",
|
phone: "+7 978 300-10-06",
|
||||||
role: "driver",
|
role: "driver",
|
||||||
|
|
@ -66,7 +66,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "u-admin",
|
id: "u-admin",
|
||||||
email: "admin@demo.local",
|
email: "admin@local",
|
||||||
name: "Максим Белов",
|
name: "Максим Белов",
|
||||||
phone: "+7 978 300-10-05",
|
phone: "+7 978 300-10-05",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail"
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { KpiCard } from "../components/dashboard/KpiCard";
|
import { KpiCard } from "../components/dashboard/KpiCard";
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
|
||||||
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
||||||
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
||||||
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
||||||
|
|
@ -33,7 +32,6 @@ import { Panel } from "../components/UI/Panel";
|
||||||
import { SegmentedTabs } from "../components/UI/SegmentedTabs";
|
import { SegmentedTabs } from "../components/UI/SegmentedTabs";
|
||||||
import { Select } from "../components/UI/Select";
|
import { Select } from "../components/UI/Select";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { usePwaStatus } from "../hooks/usePwaStatus";
|
|
||||||
import { useOrders } from "../hooks/useOrders";
|
import { useOrders } from "../hooks/useOrders";
|
||||||
import { AppShell } from "../layouts/AppShell";
|
import { AppShell } from "../layouts/AppShell";
|
||||||
import {
|
import {
|
||||||
|
|
@ -52,7 +50,6 @@ import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const { installApp, isInstallAvailable, isInstalled, isOfflineReady, isOnline } = usePwaStatus();
|
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
const [activeSection, setActiveSection] = React.useState("overview");
|
const [activeSection, setActiveSection] = React.useState("overview");
|
||||||
const [overviewTab, setOverviewTab] = React.useState("pulse");
|
const [overviewTab, setOverviewTab] = React.useState("pulse");
|
||||||
|
|
@ -293,10 +290,6 @@ const {
|
||||||
setDropColumnKey(null);
|
setDropColumnKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInstallApp = async () => {
|
|
||||||
await installApp();
|
|
||||||
};
|
|
||||||
|
|
||||||
const overviewTabs = [
|
const overviewTabs = [
|
||||||
{ key: "pulse", label: "Пульс" },
|
{ key: "pulse", label: "Пульс" },
|
||||||
{ key: "events", label: "События" },
|
{ key: "events", label: "События" },
|
||||||
|
|
@ -520,13 +513,6 @@ const {
|
||||||
</Panel>
|
</Panel>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PwaDemoPanel
|
|
||||||
isInstallAvailable={isInstallAvailable}
|
|
||||||
isInstalled={isInstalled}
|
|
||||||
isOfflineReady={isOfflineReady}
|
|
||||||
isOnline={isOnline}
|
|
||||||
onInstall={handleInstallApp}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
||||||
import { DEMO_LOGIN_EMAIL, resolveLoginEmail, useAuth } from "../context/AuthContext";
|
import {
|
||||||
|
DEMO_LOGIN_EMAIL,
|
||||||
|
isRoleSwitchEntryEmail,
|
||||||
|
resolveLoginEmail,
|
||||||
|
useAuth,
|
||||||
|
} from "../context/AuthContext";
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp } = useAuth();
|
const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp, signInWithRole } = useAuth();
|
||||||
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
||||||
const [roleHint, setRoleHint] = React.useState("manager");
|
const [roleHint, setRoleHint] = React.useState("manager");
|
||||||
const [otp, setOtp] = React.useState("");
|
const [otp, setOtp] = React.useState("");
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
|
const isRoleSwitchMode = !isOtpSent && isRoleSwitchEntryEmail(email);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
|
|
@ -23,6 +29,16 @@ export const LoginPage = () => {
|
||||||
const displayError = error || authError;
|
const displayError = error || authError;
|
||||||
|
|
||||||
const handleRequestOtp = async () => {
|
const handleRequestOtp = async () => {
|
||||||
|
if (isRoleSwitchMode) {
|
||||||
|
const response = await signInWithRole(roleHint);
|
||||||
|
if (!response.success) {
|
||||||
|
setError(response.error?.message || "Не удалось выполнить вход");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await requestOtp(
|
const response = await requestOtp(
|
||||||
isDemoMode
|
isDemoMode
|
||||||
? { email: resolveLoginEmail(isDemoMode, email), roleHint }
|
? { email: resolveLoginEmail(isDemoMode, email), roleHint }
|
||||||
|
|
@ -56,6 +72,7 @@ export const LoginPage = () => {
|
||||||
isOtpSent={isOtpSent}
|
isOtpSent={isOtpSent}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isDemoMode={isDemoMode}
|
isDemoMode={isDemoMode}
|
||||||
|
isRoleSwitchMode={isRoleSwitchMode}
|
||||||
onRequestOtp={handleRequestOtp}
|
onRequestOtp={handleRequestOtp}
|
||||||
onVerifyOtp={handleVerifyOtp}
|
onVerifyOtp={handleVerifyOtp}
|
||||||
error={displayError}
|
error={displayError}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,37 @@
|
||||||
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
|
const SHOWCASE_TOKEN = "showcase";
|
||||||
|
|
||||||
|
const formatIsoDate = (date) => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const addDays = (date, days) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildShowcaseInvitation = (token = SHOWCASE_TOKEN, now = new Date()) => {
|
||||||
|
const firstDay = formatIsoDate(addDays(now, 1));
|
||||||
|
const secondDay = formatIsoDate(addDays(now, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
orderId: "showcase-order",
|
||||||
|
orderNumber: "CD-CLIENT-001",
|
||||||
|
customerName: "Мария Волкова",
|
||||||
|
customerPhone: "+7 978 000-12-31",
|
||||||
|
state: "awaiting_choice",
|
||||||
|
deliveryDate: firstDay,
|
||||||
|
deliveryTime: "До обеда",
|
||||||
|
availableSlots: [
|
||||||
|
`${firstDay}, До обеда`,
|
||||||
|
`${firstDay}, После обеда`,
|
||||||
|
`${secondDay}, До обеда`,
|
||||||
|
`${secondDay}, После обеда`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const invokeDeliveryFunction = async (functionName, body) => {
|
const invokeDeliveryFunction = async (functionName, body) => {
|
||||||
if (!hasSupabaseConfig || !supabase?.functions?.invoke) {
|
if (!hasSupabaseConfig || !supabase?.functions?.invoke) {
|
||||||
throw new Error("Supabase is not configured");
|
throw new Error("Supabase is not configured");
|
||||||
|
|
@ -17,7 +49,9 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchDeliveryInvitation = async (token) =>
|
export const fetchDeliveryInvitation = async (token) =>
|
||||||
(await invokeDeliveryFunction("get-delivery-invitation", { token })).invitation;
|
(token === SHOWCASE_TOKEN
|
||||||
|
? buildShowcaseInvitation(token)
|
||||||
|
: (await invokeDeliveryFunction("get-delivery-invitation", { token })).invitation);
|
||||||
|
|
||||||
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) =>
|
export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime }) =>
|
||||||
invokeDeliveryFunction("confirm-delivery-choice", {
|
invokeDeliveryFunction("confirm-delivery-choice", {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ vi.mock("../supabaseClient", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildShowcaseInvitation,
|
||||||
confirmDeliveryChoice,
|
confirmDeliveryChoice,
|
||||||
fetchDeliveryInvitation,
|
fetchDeliveryInvitation,
|
||||||
reportDeliveryResult,
|
reportDeliveryResult,
|
||||||
|
|
@ -50,6 +51,28 @@ describe("deliveryInvitationApi", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a local showcase invitation for the preview token", async () => {
|
||||||
|
await expect(fetchDeliveryInvitation("showcase")).resolves.toMatchObject({
|
||||||
|
token: "showcase",
|
||||||
|
orderNumber: "CD-CLIENT-001",
|
||||||
|
customerName: "Мария Волкова",
|
||||||
|
state: "awaiting_choice",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds showcase slots for tomorrow and the following day", () => {
|
||||||
|
const invitation = buildShowcaseInvitation("showcase", new Date("2026-04-14T09:00:00Z"));
|
||||||
|
|
||||||
|
expect(invitation.availableSlots).toEqual([
|
||||||
|
"2026-04-15, До обеда",
|
||||||
|
"2026-04-15, После обеда",
|
||||||
|
"2026-04-16, До обеда",
|
||||||
|
"2026-04-16, После обеда",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("confirms a delivery choice with the chosen slot", async () => {
|
it("confirms a delivery choice with the chosen slot", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
invoke.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue