From e9a8e0ffc260c6bec129315e27df0af9eb928f1c Mon Sep 17 00:00:00 2001 From: kirukib Date: Fri, 7 Aug 2026 16:11:36 +0300 Subject: [PATCH] Add multi-provider staff login and Rx dosing schedules. Google, phone OTP, and Telegram join email auth; prescription lines carry frequency, intervalHours, and times, with backend spec updated for this batch. Co-authored-by: Cursor --- .env.example | 8 + docs/admin-backend-spec.md | 181 ++++++- src/lib/dosingSchedule.ts | 56 ++ src/locales/am/translation.json | 41 +- src/locales/en/translation.json | 41 +- src/mocks/data.ts | 161 +++++- src/pages/auth/LoginPage.tsx | 501 ++++++++++++++---- .../prescriptions/CreatePrescriptionPage.tsx | 130 ++++- .../prescriptions/PrescriptionDetailPage.tsx | 28 +- src/types/index.ts | 29 +- src/vite-env.d.ts | 4 + src/zustand/authStore.ts | 67 ++- 12 files changed, 1068 insertions(+), 179 deletions(-) create mode 100644 src/lib/dosingSchedule.ts diff --git a/.env.example b/.env.example index 23a40e5..b3a87d2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,10 @@ VITE_USE_MOCKS=true VITE_API_BASE_URL= + +# Auth providers (placeholders — do not commit real secrets) +# Google OAuth Web client ID for Admin SPA (Google Identity Services) +VITE_GOOGLE_CLIENT_ID= +# Telegram Login Widget bot username (without @) +VITE_TELEGRAM_BOT_USERNAME= +# Optional: Twilio / AfricasTalking / other OTP provider keys belong on the backend only +# VITE_PUBLIC_* client flags may reference provider names but never API secrets diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md index d01fd78..ac8cab2 100644 --- a/docs/admin-backend-spec.md +++ b/docs/admin-backend-spec.md @@ -38,6 +38,11 @@ | 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` | | 2026-08-07 | **Doctor** actor: hospital-affiliated clinicians; Admin roster under organisations; create Rx & orders; guest patients | | 2026-08-07 | **Guest / unauthenticated orders**: `customerType` registered\|guest; staff/doctor authenticated, end customer optional | +| 2026-08-07 | Rx medication schedule: `frequency`, `intervalHours`, `times[]` on prescription line items; Admin create/detail + `dosingSchedule` helpers | +| 2026-08-07 | Catalogue **multi-UOM**: `uoms[]` with `conversionFactor` into stock base; `toStockQty` / `fromStockQty` | +| 2026-08-07 | Catalogue **storefront / mobile PDP** fields + Admin detail preview (`storefrontPdpPreview`) | +| 2026-08-07 | Auth providers: Google, Email, Phone (OTP), Telegram — staff login UI + endpoint sketches | +| 2026-08-07 | **Push batch:** multi-provider staff login + Rx dosing schedule (Admin UI/mocks) + spec alignment for doctor/guest orders, multi-UOM, storefront PDP; open questions 8–17 | ## Conventions @@ -68,15 +73,30 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or ## Auth & me +Staff Admin authenticates with **Bearer JWT**. Login supports multiple identity providers; all paths mint (or exchange into) the same staff access/refresh pair and `GET /auth/me` principal. + | Method | Path | Roles | Description | | --- | --- | --- | --- | | POST | `/auth/staff/login` | — | Email/password → tokens + user | +| POST | `/auth/staff/oauth/google` | — | Exchange Google ID token (GIS / OAuth) → staff tokens + user | +| POST | `/auth/staff/phone/start` | — | `{ phone }` → start OTP (rate-limited); `{ challengeId, expiresIn }` | +| POST | `/auth/staff/phone/verify` | — | `{ challengeId, code }` → tokens + user when phone is linked to a staff account | +| POST | `/auth/staff/telegram` | — | Telegram Login Widget payload (hash-verified) → tokens + user | | POST | `/auth/refresh` | refresh | Rotate access | | POST | `/auth/logout` | staff | Revoke | -| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl | +| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl, `authProvider` | | PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` | | POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` | +### Auth providers + +| Provider | Flow | Staff Admin | Doctor / external | +| --- | --- | --- | --- | +| **Email** | `POST /auth/staff/login` with password (magic-link optional later — not in Admin mock) | Primary for pharmacy staff | Doctor uses `POST /auth/doctor/login` (email/invite); same IdP may be shared later with `actor=doctor` | +| **Google** | Browser Google Identity Services → ID token → `POST /auth/staff/oauth/google` | Allowed when staff email is in Google Workspace / allow-listed domain | TBD for doctors (hospital Google vs separate realm) | +| **Phone (OTP)** | `phone/start` → SMS/WhatsApp OTP → `phone/verify` | Staff accounts with verified `phone` on user profile | Useful for guest order claim / patient flows (out of Admin staff session); doctor phone login TBD | +| **Telegram** | [Telegram Login Widget](https://core.telegram.org/widgets/login) on Admin SPA → server verifies `hash` with bot token → `POST /auth/staff/telegram` | Optional for staff who linked Telegram id; also used for **rider** dispatch identity (see Dispatch) | Not primary for doctor clinical auth | + **Login response sketch:** ```json @@ -90,13 +110,33 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or "role": "pharmacist", "branchId": "uuid", "preferredLocale": "en", - "avatarUrl": "https://cdn.example/avatars/hana.jpg" + "avatarUrl": "https://cdn.example/avatars/hana.jpg", + "authProvider": "email" } } ``` -Mock key: `authStore.login` → `/auth/staff/login` -UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile” +`authProvider` values: `email` | `google` | `phone` | `telegram`. + +**Google body sketch:** `{ "idToken": "..." }` +**Phone start:** `{ "phone": "+251911000000" }` +**Phone verify:** `{ "challengeId": "uuid", "code": "123456" }` +**Telegram body:** widget fields (`id`, `first_name`, `username`, `auth_date`, `hash`, …) as returned by the Login Widget. + +Mock keys: +- `authStore.login` → `/auth/staff/login` +- `authStore.loginWithProvider('google'|'phone'|'telegram')` → oauth / phone verify / telegram (Admin SPA mock signs in as demo pharmacist unless overridden) + +UI: `/login` (provider buttons + email form + phone OTP step + Telegram CTA + demo picker), `/profile` (every authenticated role; not permission-gated), topbar “My profile” + +Env placeholders: + +| Side | Vars | +| --- | --- | +| Client (Admin SPA / `.env.example`) | `VITE_GOOGLE_CLIENT_ID`, `VITE_TELEGRAM_BOT_USERNAME` — empty = mock provider UX | +| Server only (never ship to Vite) | Google token audience / Workspace allow-list; `TELEGRAM_BOT_TOKEN` (Login Widget hash verify); SMS/OTP gateway credentials (AfricasTalking / Twilio / …) | + +Typed on client as `ImportMetaEnv` in `src/vite-env.d.ts`. --- @@ -159,10 +199,68 @@ UI: `/orders/new` (Guest customer vs Registered customer toggle), `/orders/:id` | POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` | | POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time | | GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report | -| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items, controlled flags, review trail, linked doctor | +| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items (incl. dosing schedule), controlled flags, review trail, linked doctor | | GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight | -UI: `/prescriptions/new` (doctor select), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. +UI: `/prescriptions/new` (doctor select + per-line dosing schedule), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. Medications table shows interval/frequency and dose times. + +### Prescription medication line items (schedule) + +Each `items[]` entry on create / detail carries dosing instructions used by pharmacist counsel and **mobile dose reminders**. Clock `times` use the same shape as mobile `Reminder.times` (`HH:mm` strings, 24h). Backend should persist the pharmacist’s final `times` list (and `intervalHours` for QXH) so the patient app can seed reminders without re-deriving. + +| Field | Type | Notes | +| --- | --- | --- | +| `name` | string | Medicine label / strength as entered | +| `qty` | number | Dispense quantity | +| `controlled?` | boolean | Controlled-substance flag | +| `frequency?` | `QD` \| `BID` \| `TID` \| `QID` \| `QXH` \| `custom` | Once / 2× / 3× / 4× daily, every N hours, or custom clock list | +| `intervalHours?` | number | **Required** when `frequency === "QXH"` (hours between doses, ≥ 1) | +| `times?` | string[] | Clock times `HH:mm` (24h), e.g. `["08:00","14:00","20:00"]`. Admin intake requires ≥ 1 time | + +**Frequency → default suggested times** (Admin `defaultTimesForFrequency`; pharmacist may add/remove slots): + +| Frequency | Default `times` | +| --- | --- | +| `QD` | `08:00` | +| `BID` | `08:00`, `20:00` | +| `TID` | `08:00`, `14:00`, `20:00` | +| `QID` | `08:00`, `12:00`, `16:00`, `20:00` | +| `QXH` | Generated from `intervalHours` starting 08:00 until midnight (default interval 8h if omitted in UI) | +| `custom` | Seed `08:00`; pharmacist edits freely | + +**Create body sketch:** `POST /pharmacy/prescriptions` / `POST /doctor/prescriptions`: + +```json +{ + "customerName": "Guest patient · no account", + "branchId": "uuid", + "doctorId": "uuid", + "orgId": "uuid", + "items": [ + { + "name": "Amoxicillin 500mg", + "qty": 21, + "controlled": false, + "frequency": "TID", + "times": ["08:00", "14:00", "20:00"] + }, + { + "name": "Paracetamol 500mg", + "qty": 20, + "frequency": "QXH", + "intervalHours": 6, + "times": ["08:00", "14:00", "20:00"] + } + ] +} +``` + +Rx header also stores denormalised `prescriber` (doctor display name). Patient on Admin intake may be a **guest name** (no CRM `customerId` required). + +Helper (Admin): `src/lib/dosingSchedule.ts` — `DOSING_FREQUENCY_OPTIONS`, `defaultTimesForFrequency`, `formatFrequencyLabel`, `formatDoseTimes`. + +Mock: `mocks/data.prescriptions` · Types: `PrescriptionItem`, `DosingFrequency` +UI: create sets schedule per line; detail medications table shows frequency label + dose times. --- @@ -219,18 +317,54 @@ Master product identity. **Stock rows reference an Item** via `itemId`; batch/lo 1. Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[] 2. Classification — product type (`OTC`\|`Rx`\|`Controlled`\|`Medical device`\|`Beauty`\|`Wellness`), therapeutic class, activeIngredients[], dosage form, route, controlled schedule -3. Packaging — pack size, base UoM, sell-by vs pack unit, parent/variant links -4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref +3. Packaging — pack size, `baseUnit` (stock UOM), `sellByUnit`, `packUnit`, **`uoms[]` multi-UOM table**, parent/variant links +4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref, optional `compareAtPriceEtb` 5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction -6. Content — descriptions, dosage guidance, side effects, warnings, storage, similar alternatives[] (display text only) -7. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock) -8. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description -9. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps -10. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at -11. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**) +6. Content — short description, dosage guidance, side effects (free text + structured mild/severe), warnings, storage (+ structured `storageNotes[]`), `directions[]`, `pharmacistTip`, `useCase`, similar alternatives[] +7. Storefront / mobile PDP — fields below; Admin detail shows a live preview shaped for Gishen mobile +8. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock) +9. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description +10. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps +11. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at +12. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**) -Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, …) -UI: `/items`, `/items/new`, `/items/:id` +### Multi-UOM (ERPNext-style) + +Warehouse quantity is **always** counted in the stock / base UOM (`baseUnit`). Additional purchase/sales units convert via `conversionFactor` = how many **stock units** equal one of that UOM. The stock UOM row always has `conversionFactor: 1`. + +| Field | Type | Notes | +| --- | --- | --- | +| `uoms[]` | `ItemUom[]` | Allowed units for the item | +| `uoms[].uom` | string | e.g. `tablet`, `strip`, `box` | +| `uoms[].conversionFactor` | number | Stock qty per 1 of this UOM | +| `uoms[].isStockUom?` | boolean | Exactly one true; matches `baseUnit` | +| `uoms[].isPurchaseUom?` | boolean | Used on PO / receive | +| `uoms[].isSalesUom?` | boolean | Used on sales / POS lines | + +**Helpers (Admin mock / shared):** `resolveItemUoms` (falls back to `buildDefaultUoms` from pack labels when `uoms` empty), `toStockQty(qty, fromUom, item)`, `fromStockQty(stockQty, toUom, item)`, `stockUomOf(item)`. + +**API expectation:** `POST`/`PATCH` `/admin/catalog/items` accept `uoms`; stock receive / POS / order lines may submit qty in a sales/purchase UOM — server converts with `toStockQty` before decrementing inventory. Reject unknown UOM labels. + +Example: `1 strip = 10 tablet` → selling 2 strips deducts `20` stock units. + +### Storefront / mobile PDP fields + +Aligned with Gishen mobile product detail (`product/[id]`). Admin create/edit captures these; detail page previews via `storefrontPdpPreview(item)`. + +| Field | Notes | +| --- | --- | +| `storefrontUnit?` | Shelf / pack label shown as mobile `product.unit` (e.g. `10 capsules/strip`); falls back to `packSize` / `sellByUnit` | +| `useCase?` | Chip above name (e.g. Pain & Fever); falls back to therapeutic class | +| `compareAtPriceEtb?` | Strike-through compare-at price | +| `sideEffectsMild?` / `sideEffectsSevere?` | Structured lists; mild falls back to parsing free-text `sideEffects` | +| `directions?` | `{ title, body }[]` stepper; falls back from `dosageGuidance` + `warnings` | +| `storageNotes?` | `{ title, body }[]` cards; falls back from `storageInstructions` | +| `pharmacistTip?` | Callout; falls back to `warnings` | + +Preview mapping also exposes name, brand, slug, sku, price, images, Rx flag, badge, form, generic, drug class, ingredient strengths. + +Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, `ItemUom`, …) +UI: `/items`, `/items/new` (multi-UOM editor + PDP fields), `/items/:id` (UOM table + Storefront / Mobile PDP section) Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … }` over free-typed product fields. UI deep-link `?item=` --- @@ -240,7 +374,7 @@ Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … | Method | Path | Roles | Description | | --- | --- | --- | --- | | GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` | -| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, reorderPoint? }` — must not invent a new Item | +| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, uom?, reorderPoint? }` — `qty` may be in a purchase UOM; server converts via item `uoms` → stock base. Must not invent a new Item | | GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync | | GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP | | POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve | @@ -473,8 +607,9 @@ Palette results are permission-filtered with the same `roleModules` map the route guards use, so no backend change is needed — but any new module key added server-side must also exist in `ModuleKey` and `roleModules`. -Current SPA destinations of note: `/pos`, `/items`, `/profile` (palette + topbar; -not a gated module), Payment Gateways `/finance/gateways` (palette-only). +Current SPA destinations of note: `/pos`, `/items`, `/doctors` (orgs module — +hospital/clinic roster), `/profile` (palette + topbar; not a gated module), +Payment Gateways `/finance/gateways` (palette-only). Entity detail routes (`orders/:id`, `stock/:sku`, `items/:id`, …) are deliberately not registered: they require a record id. Register the list page @@ -502,4 +637,10 @@ or admin `POST /admin/users/:id/avatar`. 8. Doctor identity provider: dedicated doctor realm vs B2B portal users with `role=doctor` under hospital orgs 9. Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII? 10. Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance -11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs? +11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs? +12. Multi-UOM source of truth: Platform-editable vs ERP-synced conversion factors — which field-group ownership wins on conflict? +13. Mobile reminder sync: push dose `times` on Rx approve vs on first dispense / patient claim? +14. Google OAuth: production Web client ID + allowed staff email domains / Workspace org for Admin +15. Telegram Login Widget: production bot username and whether staff must pre-link Telegram id in Admin before first login +16. OTP provider for staff phone login: AfricasTalking vs Twilio vs local SMS gateway; shared with guest-order claim OTP? +17. Should doctor login reuse Google / phone / Telegram providers or stay email+invite only? diff --git a/src/lib/dosingSchedule.ts b/src/lib/dosingSchedule.ts new file mode 100644 index 0000000..255e2ed --- /dev/null +++ b/src/lib/dosingSchedule.ts @@ -0,0 +1,56 @@ +import type { DosingFrequency } from '@/types' + +/** Common frequency options for pharmacist Rx intake. */ +export const DOSING_FREQUENCY_OPTIONS: { + value: DosingFrequency + label: string + short: string +}[] = [ + { value: 'QD', label: 'Once daily (QD)', short: 'Once daily' }, + { value: 'BID', label: 'Twice daily (BID)', short: 'Twice daily' }, + { value: 'TID', label: 'Three times daily (TID)', short: '3× daily' }, + { value: 'QID', label: 'Four times daily (QID)', short: '4× daily' }, + { value: 'QXH', label: 'Every N hours', short: 'Every N hours' }, + { value: 'custom', label: 'Custom times', short: 'Custom' }, +] + +const DEFAULT_TIMES: Record, string[]> = { + QD: ['08:00'], + BID: ['08:00', '20:00'], + TID: ['08:00', '14:00', '20:00'], + QID: ['08:00', '12:00', '16:00', '20:00'], +} + +/** Sensible clock times when the pharmacist picks a common frequency. */ +export function defaultTimesForFrequency( + frequency: DosingFrequency, + intervalHours?: number, +): string[] { + if (frequency === 'custom') return ['08:00'] + if (frequency === 'QXH') { + const hours = intervalHours && intervalHours > 0 ? intervalHours : 8 + const slots: string[] = [] + for (let h = 8; h < 24 && slots.length < Math.floor(24 / hours); h += hours) { + slots.push(`${String(h).padStart(2, '0')}:00`) + } + return slots.length ? slots : ['08:00'] + } + return [...DEFAULT_TIMES[frequency]] +} + +export function formatFrequencyLabel( + frequency?: DosingFrequency, + intervalHours?: number, +): string { + if (!frequency) return '—' + if (frequency === 'QXH') { + const n = intervalHours && intervalHours > 0 ? intervalHours : undefined + return n ? `Every ${n} hours` : 'Every N hours' + } + return DOSING_FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.short ?? frequency +} + +export function formatDoseTimes(times?: string[]): string { + if (!times?.length) return '—' + return times.join(' · ') +} diff --git a/src/locales/am/translation.json b/src/locales/am/translation.json index cf2ae21..b2993ba 100644 --- a/src/locales/am/translation.json +++ b/src/locales/am/translation.json @@ -7,17 +7,54 @@ "auth": { "login": "ግባ", "email": "ኢሜይል", + "phone": "ስልክ", "password": "የይለፍ ቃል", "welcome": "እንኳን ደህና መጡ", - "subtitle": "የሙከራ ሚና ይምረጡ ወይም በሰራተኛ መለያ ይግቡ።", + "subtitle": "በGoogle፣ ኢሜይል፣ ስልክ ወይም ቴሌግራም ይግቡ — ወይም የሙከራ ሚና ይምረጡ።", "demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123", "demoAccounts": "የሙከራ መለያዎች", + "demoPickHint": "ፎርሙን ለመሙላት እና በቀጥታ ለመግባት የሙከራ ተጠቃሚ ይምረጡ።", + "chooseTestUser": "የሙከራ ተጠቃሚ ይምረጡ", + "signingInAs": "እንደ {{email}} በመግባት ላይ…", "signedIn": "ገብተዋል", "signedOut": "ወጥተዋል", + "continueGoogle": "በGoogle ቀጥል", + "continueTelegram": "በ{{bot}} ቀጥል", + "connecting": "በመገናኘት ላይ…", + "googleMockHint": "የሙከራ Google መግባት (ለእውነተኛ OAuth VITE_GOOGLE_CLIENT_ID ያዘጋጁ)።", + "telegramMockHint": "የሙከራ Telegram Login Widget (VITE_TELEGRAM_BOT_USERNAME ያዘጋጁ)።", + "telegramWidgetHint": "የTelegram Login Widget ዘይቤ — bot @{{bot}}።", + "orContinueWith": "ወይም በዚህ ይቀጥሉ", + "credentialTabs": "ኢሜይል ወይም ስልክ መግባት", + "forgotPassword": "የይለፍ ቃል ረሱ?", + "resetTitle": "የይለፍ ቃል መቀየር", + "resetBody": "ይህ ኮንሶል በሙከራ ውሂብ ላይ ይሰራል፤ የማስተካከያ ኢሜይል የለም። አዲስ የይለፍ ቃል ለማስቀመጥ ሱፐር አድሚንን ጠይቁ።", + "rememberMe": "በዚህ መሣሪያ ላይ አስታውሰኝ", + "signingIn": "በመግባት ላይ…", + "showPassword": "የይለፍ ቃል አሳይ", + "hidePassword": "የይለፍ ቃል ደብቅ", + "phoneHint": "የኢትዮጵያ ሞባይል ቁጥሮች ይሻላሉ (+251…)። የሙከራ OTP፡ 123456።", + "sendCode": "የማረጋገጫ ኮድ ላክ", + "sendingCode": "ኮድ በመላክ ላይ…", + "otp": "የማረጋገጫ ኮድ", + "otpSent": "ኮድ ተልኳል", + "otpSentHint": "የሙከራ SMS፡ ለመቀጠል {{code}} ያስገቡ።", + "otpHint": "ወደ {{phone}} ተልኳል። የሙከራ ኮድ {{code}} ይጠቀሙ።", + "changePhone": "ሌላ ቁጥር ይጠቀሙ", + "verifyAndSignIn": "አረጋግጥ እና ግባ", + "providerSignedIn": "በ{{provider}}", + "providers": { + "email": "ኢሜይል", + "google": "Google", + "phone": "ስልክ", + "telegram": "ቴሌግራም" + }, "error": { "invalidCredentials": "የተሳሳተ ኢሜይል ወይም የይለፍ ቃል", "emailRequired": "የኢሜይል አድራሻዎን ያስገቡ", - "passwordRequired": "የይለፍ ቃልዎን ያስገቡ" + "passwordRequired": "የይለፍ ቃልዎን ያስገቡ", + "phoneInvalid": "ትክክለኛ ስልክ ቁጥር ያስገቡ", + "otpInvalid": "ትክክል ያልሆነ ኮድ። የሙከራ OTP፡ 123456።" } }, "nav": { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a7ce20c..0ba7895 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -7,17 +7,54 @@ "auth": { "login": "Sign in", "email": "Email", + "phone": "Phone", "password": "Password", "welcome": "Welcome back", - "subtitle": "Choose a demo role or sign in with your staff account.", + "subtitle": "Sign in with Google, email, phone, or Telegram — or pick a demo role.", "demoHint": "Demo password for all accounts: demo123", "demoAccounts": "Demo accounts", + "demoPickHint": "Pick a test user to fill the form and sign in straight away.", + "chooseTestUser": "Choose a test user", + "signingInAs": "Signing in as {{email}}…", "signedIn": "Signed in", "signedOut": "Signed out", + "continueGoogle": "Continue with Google", + "continueTelegram": "Continue with {{bot}}", + "connecting": "Connecting…", + "googleMockHint": "Mock Google sign-in (set VITE_GOOGLE_CLIENT_ID for real OAuth).", + "telegramMockHint": "Mock Telegram Login Widget (set VITE_TELEGRAM_BOT_USERNAME).", + "telegramWidgetHint": "Telegram Login Widget style — bot @{{bot}}.", + "orContinueWith": "or continue with", + "credentialTabs": "Email or phone sign-in", + "forgotPassword": "Forgot password?", + "resetTitle": "Password reset", + "resetBody": "This console runs on demo data, so there is no reset email. Ask a super admin to set a new password.", + "rememberMe": "Remember me on this device", + "signingIn": "Signing in…", + "showPassword": "Show password", + "hidePassword": "Hide password", + "phoneHint": "Ethiopian mobile numbers work best (+251…). Demo OTP is 123456.", + "sendCode": "Send verification code", + "sendingCode": "Sending code…", + "otp": "Verification code", + "otpSent": "Code sent", + "otpSentHint": "Demo SMS: enter {{code}} to continue.", + "otpHint": "Sent to {{phone}}. Use demo code {{code}}.", + "changePhone": "Use a different number", + "verifyAndSignIn": "Verify and sign in", + "providerSignedIn": "Via {{provider}}", + "providers": { + "email": "Email", + "google": "Google", + "phone": "Phone", + "telegram": "Telegram" + }, "error": { "invalidCredentials": "Invalid credentials", "emailRequired": "Enter your email address", - "passwordRequired": "Enter your password" + "passwordRequired": "Enter your password", + "phoneInvalid": "Enter a valid phone number", + "otpInvalid": "Incorrect code. Demo OTP is 123456." } }, "nav": { diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 7e74176..f6973f1 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -31,8 +31,19 @@ export let prescriptions: Prescription[] = [ branchId: 'br-bole', status: 'under_review', items: [ - { name: 'Amoxicillin 500mg', qty: 21 }, - { name: 'Paracetamol 500mg', qty: 10 }, + { + name: 'Amoxicillin 500mg', + qty: 21, + frequency: 'TID', + times: ['08:00', '14:00', '20:00'], + }, + { + name: 'Paracetamol 500mg', + qty: 10, + frequency: 'QXH', + intervalHours: 6, + times: ['08:00', '14:00', '20:00'], + }, ], submittedAt: '2026-08-06T08:10:00Z', prescriber: 'Dr. Selam', @@ -42,7 +53,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Marta Hailu', branchId: 'br-bole', status: 'submitted', - items: [{ name: 'Metformin 850mg', qty: 60, controlled: false }], + items: [ + { + name: 'Metformin 850mg', + qty: 60, + controlled: false, + frequency: 'BID', + times: ['08:00', '20:00'], + }, + ], submittedAt: '2026-08-06T09:20:00Z', prescriber: 'Dr. Yohannes', }, @@ -51,7 +70,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Tigist Alemu', branchId: 'br-cmc', status: 'under_review', - items: [{ name: 'Tramadol 50mg', qty: 20, controlled: true }], + items: [ + { + name: 'Tramadol 50mg', + qty: 20, + controlled: true, + frequency: 'QID', + times: ['08:00', '12:00', '16:00', '20:00'], + }, + ], submittedAt: '2026-08-06T07:45:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-08-06T08:05:00Z', @@ -61,7 +88,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Dawit Lemma', branchId: 'br-piassa', status: 'submitted', - items: [{ name: 'Salbutamol inhaler', qty: 2 }], + items: [ + { + name: 'Salbutamol inhaler', + qty: 2, + frequency: 'custom', + times: ['08:00', '14:00', '21:00'], + }, + ], submittedAt: '2026-08-05T16:30:00Z', prescriber: 'Dr. Kalkidan', }, @@ -70,7 +104,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Hiwot Bekele', branchId: 'br-cmc', status: 'queried', - items: [{ name: 'Amlodipine 5mg', qty: 30 }], + items: [{ name: 'Amlodipine 5mg', qty: 30, frequency: 'QD', times: ['08:00'] }], submittedAt: '2026-08-05T11:15:00Z', prescriber: 'Dr. Yohannes', reviewStartedAt: '2026-08-05T11:40:00Z', @@ -83,7 +117,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Netsanet Desta', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Cetirizine 10mg', qty: 20 }], + items: [{ name: 'Cetirizine 10mg', qty: 20, frequency: 'QD', times: ['21:00'] }], submittedAt: '2026-08-04T09:10:00Z', prescriber: 'Dr. Selam', reviewStartedAt: '2026-08-04T09:25:00Z', @@ -96,8 +130,8 @@ export let prescriptions: Prescription[] = [ branchId: 'br-bole', status: 'approved', items: [ - { name: 'Metformin 850mg', qty: 60 }, - { name: 'Amlodipine 5mg', qty: 30 }, + { name: 'Metformin 850mg', qty: 60, frequency: 'BID', times: ['08:00', '20:00'] }, + { name: 'Amlodipine 5mg', qty: 30, frequency: 'QD', times: ['08:00'] }, ], submittedAt: '2026-08-03T14:05:00Z', prescriber: 'Dr. Helen', @@ -110,7 +144,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Bekele Tesfaye', branchId: 'br-cmc', status: 'rejected', - items: [{ name: 'Diazepam 5mg', qty: 14, controlled: true }], + items: [ + { + name: 'Diazepam 5mg', + qty: 14, + controlled: true, + frequency: 'BID', + times: ['08:00', '20:00'], + }, + ], submittedAt: '2026-08-02T10:40:00Z', prescriber: 'Dr. Unknown', reviewStartedAt: '2026-08-02T11:00:00Z', @@ -123,7 +165,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Yeshi Lemma', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Ferrous sulphate 200mg', qty: 30 }], + items: [{ name: 'Ferrous sulphate 200mg', qty: 30, frequency: 'QD', times: ['08:00'] }], submittedAt: '2026-08-01T08:20:00Z', prescriber: 'Dr. Selam', reviewStartedAt: '2026-08-01T08:35:00Z', @@ -135,7 +177,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Sofia Ahmed', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Azithromycin 250mg', qty: 6 }], + items: [{ name: 'Azithromycin 250mg', qty: 6, frequency: 'QD', times: ['08:00'] }], submittedAt: '2026-07-31T13:45:00Z', prescriber: 'Dr. Yohannes', reviewStartedAt: '2026-07-31T14:00:00Z', @@ -147,7 +189,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Genet Abera', branchId: 'br-piassa', status: 'approved', - items: [{ name: 'Hydrocortisone cream 1%', qty: 2 }], + items: [ + { + name: 'Hydrocortisone cream 1%', + qty: 2, + frequency: 'BID', + times: ['08:00', '20:00'], + }, + ], submittedAt: '2026-07-30T11:05:00Z', prescriber: 'Dr. Kalkidan', reviewStartedAt: '2026-07-30T11:20:00Z', @@ -159,7 +208,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Solomon Wolde', branchId: 'br-cmc', status: 'queried', - items: [{ name: 'Ibuprofen 400mg', qty: 30 }], + items: [ + { + name: 'Ibuprofen 400mg', + qty: 30, + frequency: 'QXH', + intervalHours: 8, + times: ['08:00', '16:00'], + }, + ], submittedAt: '2026-07-29T15:20:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-07-29T15:35:00Z', @@ -172,7 +229,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Rahel Tadesse', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Amoxicillin 500mg', qty: 21 }], + items: [ + { + name: 'Amoxicillin 500mg', + qty: 21, + frequency: 'TID', + times: ['08:00', '14:00', '20:00'], + }, + ], submittedAt: '2026-07-28T09:30:00Z', prescriber: 'Dr. Selam', reviewStartedAt: '2026-07-28T09:45:00Z', @@ -184,7 +248,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Meseret Kassa', branchId: 'br-cmc', status: 'approved', - items: [{ name: 'Oral rehydration salts', qty: 10 }], + items: [ + { + name: 'Oral rehydration salts', + qty: 10, + frequency: 'custom', + times: ['08:00', '12:00', '16:00', '20:00'], + }, + ], submittedAt: '2026-07-26T16:10:00Z', prescriber: 'Dr. Yohannes', reviewStartedAt: '2026-07-26T16:25:00Z', @@ -196,7 +267,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Tesfaye Negash', branchId: 'br-bole', status: 'rejected', - items: [{ name: 'Tramadol 50mg', qty: 40, controlled: true }], + items: [ + { + name: 'Tramadol 50mg', + qty: 40, + controlled: true, + frequency: 'QID', + times: ['08:00', '12:00', '16:00', '20:00'], + }, + ], submittedAt: '2026-07-24T12:00:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-07-24T12:15:00Z', @@ -209,7 +288,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Kalkidan Berhe', branchId: 'br-piassa', status: 'approved', - items: [{ name: 'Paracetamol 500mg', qty: 20 }], + items: [ + { + name: 'Paracetamol 500mg', + qty: 20, + frequency: 'QXH', + intervalHours: 6, + times: ['06:00', '12:00', '18:00'], + }, + ], submittedAt: '2026-07-22T10:15:00Z', prescriber: 'Dr. Kalkidan', reviewStartedAt: '2026-07-22T10:30:00Z', @@ -222,8 +309,8 @@ export let prescriptions: Prescription[] = [ branchId: 'br-bole', status: 'approved', items: [ - { name: 'Vitamin C 1000mg', qty: 2 }, - { name: 'Ferrous sulphate 200mg', qty: 30 }, + { name: 'Vitamin C 1000mg', qty: 2, frequency: 'QD', times: ['08:00'] }, + { name: 'Ferrous sulphate 200mg', qty: 30, frequency: 'QD', times: ['08:00'] }, ], submittedAt: '2026-07-20T14:40:00Z', prescriber: 'Dr. Selam', @@ -236,7 +323,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Fikru Assefa', branchId: 'br-cmc', status: 'approved', - items: [{ name: 'Metformin 850mg', qty: 60 }], + items: [{ name: 'Metformin 850mg', qty: 60, frequency: 'BID', times: ['08:00', '20:00'] }], submittedAt: '2026-07-17T09:05:00Z', prescriber: 'Dr. Yohannes', reviewStartedAt: '2026-07-17T09:20:00Z', @@ -248,7 +335,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Mulugeta Fikadu', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Salbutamol inhaler', qty: 1 }], + items: [ + { + name: 'Salbutamol inhaler', + qty: 1, + frequency: 'custom', + times: ['08:00', '14:00', '21:00'], + }, + ], submittedAt: '2026-07-14T17:25:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-07-14T17:40:00Z', @@ -260,7 +354,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Selam Getahun', branchId: 'br-piassa', status: 'rejected', - items: [{ name: 'Azithromycin 250mg', qty: 12 }], + items: [{ name: 'Azithromycin 250mg', qty: 12, frequency: 'QD', times: ['08:00'] }], submittedAt: '2026-07-11T11:50:00Z', prescriber: 'Dr. Kalkidan', reviewStartedAt: '2026-07-11T12:05:00Z', @@ -273,7 +367,7 @@ export let prescriptions: Prescription[] = [ customerName: 'Zenebech Haile', branchId: 'br-cmc', status: 'approved', - items: [{ name: 'Cetirizine 10mg', qty: 30 }], + items: [{ name: 'Cetirizine 10mg', qty: 30, frequency: 'QD', times: ['21:00'] }], submittedAt: '2026-07-09T08:45:00Z', prescriber: 'Dr. Yohannes', reviewStartedAt: '2026-07-09T09:00:00Z', @@ -285,7 +379,14 @@ export let prescriptions: Prescription[] = [ customerName: 'Abebe Kebede', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Amoxicillin 500mg', qty: 21 }], + items: [ + { + name: 'Amoxicillin 500mg', + qty: 21, + frequency: 'TID', + times: ['08:00', '14:00', '20:00'], + }, + ], submittedAt: '2026-08-06T07:20:00Z', prescriber: 'Dr. Selam', reviewStartedAt: '2026-08-06T07:35:00Z', @@ -297,7 +398,15 @@ export let prescriptions: Prescription[] = [ customerName: 'Marta Hailu', branchId: 'br-bole', status: 'approved', - items: [{ name: 'Tramadol 50mg', qty: 20, controlled: true }], + items: [ + { + name: 'Tramadol 50mg', + qty: 20, + controlled: true, + frequency: 'QID', + times: ['08:00', '12:00', '16:00', '20:00'], + }, + ], submittedAt: '2026-08-05T15:10:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-08-05T15:25:00Z', diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 6ccc394..a24b6cf 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -4,12 +4,16 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AlertCircle, + ArrowLeft, ChevronDown, Eye, EyeOff, FlaskConical, Loader2, Lock, + Mail, + Phone, + Send, ShieldCheck, Truck, } from 'lucide-react' @@ -27,9 +31,12 @@ import { } from '@/components/ui/dropdown-menu' import { useAuthStore } from '@/zustand/authStore' import { demoAccounts } from '@/config/permissions' -import type { StaffRole } from '@/types' +import type { AuthProvider, StaffRole } from '@/types' +import { cn } from '@/lib/utils' const REMEMBER_KEY = 'gishen_remember_email' +/** Demo OTP accepted for any phone (mock SMS provider). */ +const MOCK_OTP = '123456' const ROLE_ORDER: StaffRole[] = [ 'super_admin', @@ -47,21 +54,80 @@ const VALUE_POINTS = [ { icon: Lock, text: 'Role-scoped access so each team only sees its own work' }, ] +type CredentialMode = 'email' | 'phone' +type PhoneStep = 'enter' | 'verify' + +function GoogleMark({ className }: { className?: string }) { + return ( + + ) +} + +function TelegramMark({ className }: { className?: string }) { + return ( + + ) +} + +function normalizePhone(value: string) { + return value.replace(/[\s\-()]/g, '') +} + +function isLikelyPhone(value: string) { + const digits = normalizePhone(value).replace(/^\+/, '') + return /^\d{9,15}$/.test(digits) +} + export function LoginPage() { const { t } = useTranslation() const navigate = useNavigate() const login = useAuthStore((s) => s.login) + const loginWithProvider = useAuthStore((s) => s.loginWithProvider) const token = useAuthStore((s) => s.token) || localStorage.getItem('access_token') + const [credentialMode, setCredentialMode] = useState('email') const [email, setEmail] = useState(() => localStorage.getItem(REMEMBER_KEY) ?? '') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) const [remember, setRemember] = useState(() => Boolean(localStorage.getItem(REMEMBER_KEY))) - const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({}) + const [fieldErrors, setFieldErrors] = useState<{ + email?: string + password?: string + phone?: string + otp?: string + }>({}) const [formError, setFormError] = useState(null) const [loading, setLoading] = useState(false) + const [providerBusy, setProviderBusy] = useState | null>(null) const [demoEmail, setDemoEmail] = useState(null) + const [phone, setPhone] = useState('') + const [phoneStep, setPhoneStep] = useState('enter') + const [otp, setOtp] = useState('') + const [otpSending, setOtpSending] = useState(false) + + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined + const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined + const accountsByRole = useMemo( () => ROLE_ORDER.map((role) => ({ @@ -74,21 +140,39 @@ export function LoginPage() { if (token) return - const signIn = (nextEmail: string, nextPassword: string) => { + const finishSignIn = (nextEmail: string) => { + if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail) + else localStorage.removeItem(REMEMBER_KEY) + toast.success(t('auth.signedIn')) + navigate('/') + } + + const signInEmail = (nextEmail: string, nextPassword: string) => { const result = login(nextEmail, nextPassword) if (!result.ok) { setFormError(result.error ?? t('auth.error.invalidCredentials')) toast.error(result.error ?? t('auth.error.invalidCredentials')) return false } - if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail) - else localStorage.removeItem(REMEMBER_KEY) - toast.success(t('auth.signedIn')) + finishSignIn(nextEmail) + return true + } + + const signInProvider = (provider: Exclude) => { + const result = loginWithProvider(provider) + if (!result.ok) { + setFormError(result.error ?? t('auth.error.invalidCredentials')) + toast.error(result.error ?? t('auth.error.invalidCredentials')) + return false + } + toast.success(t('auth.signedIn'), { + description: t('auth.providerSignedIn', { provider: t(`auth.providers.${provider}`) }), + }) navigate('/') return true } - const onSubmit = (e: FormEvent) => { + const onSubmitEmail = (e: FormEvent) => { e.preventDefault() const errors: { email?: string; password?: string } = {} if (!email.trim()) errors.email = t('auth.error.emailRequired') @@ -99,24 +183,67 @@ export function LoginPage() { setFormError(null) setLoading(true) window.setTimeout(() => { - const ok = signIn(email.trim(), password) + const ok = signInEmail(email.trim(), password) if (!ok) setLoading(false) }, 450) } + const onSendOtp = () => { + if (!isLikelyPhone(phone)) { + setFieldErrors((prev) => ({ ...prev, phone: t('auth.error.phoneInvalid') })) + return + } + setFieldErrors((prev) => ({ ...prev, phone: undefined, otp: undefined })) + setFormError(null) + setOtpSending(true) + window.setTimeout(() => { + setOtpSending(false) + setPhoneStep('verify') + setOtp('') + toast.message(t('auth.otpSent'), { + description: t('auth.otpSentHint', { code: MOCK_OTP }), + }) + }, 600) + } + + const onVerifyOtp = (e: FormEvent) => { + e.preventDefault() + if (otp.trim() !== MOCK_OTP) { + setFieldErrors((prev) => ({ ...prev, otp: t('auth.error.otpInvalid') })) + return + } + setFieldErrors((prev) => ({ ...prev, otp: undefined })) + setFormError(null) + setLoading(true) + window.setTimeout(() => { + const ok = signInProvider('phone') + if (!ok) setLoading(false) + }, 450) + } + + const onProviderClick = (provider: 'google' | 'telegram') => { + setFormError(null) + setProviderBusy(provider) + window.setTimeout(() => { + const ok = signInProvider(provider) + if (!ok) setProviderBusy(null) + }, 700) + } + const onPickDemoAccount = (account: (typeof demoAccounts)[number]) => { setEmail(account.email) setPassword(account.password) + setCredentialMode('email') setFieldErrors({}) setFormError(null) setDemoEmail(account.email) window.setTimeout(() => { - const ok = signIn(account.email, account.password) + const ok = signInEmail(account.email, account.password) if (!ok) setDemoEmail(null) }, 450) } - const busy = loading || demoEmail !== null + const busy = loading || demoEmail !== null || providerBusy !== null || otpSending return (
@@ -155,7 +282,7 @@ export function LoginPage() {
-
+
G @@ -179,95 +306,267 @@ export function LoginPage() {
) : null} -
-
- - { - setEmail(e.target.value) - if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined })) - }} - className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined} - /> - {fieldErrors.email ? ( -

- {fieldErrors.email} -

- ) : null} -
- -
-
- - -
-
- { - setPassword(e.target.value) - if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined })) - }} - className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`} - /> - -
- {fieldErrors.password ? ( -

- {fieldErrors.password} -

- ) : null} -
- - - - -
+ {!googleClientId ? ( +

{t('auth.googleMockHint')}

+ ) : null} + + +

+ {botUsername ? t('auth.telegramWidgetHint', { bot: botUsername }) : t('auth.telegramMockHint')} +

+
+ +
+ + {t('auth.orContinueWith')} + +
+ +
+ {( + [ + { id: 'email' as const, icon: Mail, label: t('auth.email') }, + { id: 'phone' as const, icon: Phone, label: t('auth.phone') }, + ] as const + ).map(({ id, icon: Icon, label }) => ( + + ))} +
+ + {credentialMode === 'email' ? ( +
+
+ + { + setEmail(e.target.value) + if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined })) + }} + className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined} + /> + {fieldErrors.email ? ( +

+ {fieldErrors.email} +

+ ) : null} +
+ +
+
+ + +
+
+ { + setPassword(e.target.value) + if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined })) + }} + className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`} + /> + +
+ {fieldErrors.password ? ( +

+ {fieldErrors.password} +

+ ) : null} +
+ + + + +
+ ) : ( +
+ {phoneStep === 'enter' ? ( + <> +
+ + { + setPhone(e.target.value) + if (fieldErrors.phone) setFieldErrors((prev) => ({ ...prev, phone: undefined })) + }} + className={fieldErrors.phone ? 'border-red-400 focus-visible:ring-red-400' : undefined} + /> + {fieldErrors.phone ? ( +

+ {fieldErrors.phone} +

+ ) : ( +

+ {t('auth.phoneHint')} +

+ )} +
+ + + ) : ( +
+ +
+ + { + setOtp(e.target.value.replace(/\D/g, '').slice(0, 6)) + if (fieldErrors.otp) setFieldErrors((prev) => ({ ...prev, otp: undefined })) + }} + className={cn( + 'tracking-[0.35em] font-mono text-center text-lg', + fieldErrors.otp ? 'border-red-400 focus-visible:ring-red-400' : undefined, + )} + /> + {fieldErrors.otp ? ( +

+ {fieldErrors.otp} +

+ ) : ( +

+ {t('auth.otpHint', { phone: normalizePhone(phone), code: MOCK_OTP })} +

+ )} +
+ +
+ )} +
+ )}
Demo only

- Pick a test user to fill the form and sign in straight away. {t('auth.demoHint')} + {t('auth.demoPickHint')} {t('auth.demoHint')}

@@ -303,7 +602,7 @@ export function LoginPage() {