feat(s30): QuickLoginBlock + интеграция в LoginPage (шаг 5/9)

Над обычной формой логина теперь висит жёлтый блок «Быстрый вход
(тестовый стенд)»: клик по карточке → моментальный логин через
мастер-пароль 123456 (сохранён как константа в LoginPage.tsx — должен
совпадать с server-side QuickLoginOptions.MasterPassword).

Блок сам опрашивает /api/dev/quick-login/accounts: 200 → рендерится,
404 (= прод) → возвращает null, НИЧЕГО не рисует. Так одним и тем же
web-билдом покрываются и стенд, и прод — секрета в UI нет.

Поиск: ?q= на сервере (debounced 200ms), фильтрует по email/ФИО/роли/
магазину. Карточки с badge SuperAdmin / Демо. Lazy-рендер, max-h-80
с overflow-y чтобы не ломать компоновку.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 15:41:50 +05:00
parent 35f633e206
commit 6582333def
3 changed files with 158 additions and 2 deletions

View file

@ -28,7 +28,7 @@
существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier /
Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`,
`demo-keeper@...`). Запускается только при Enabled=true.
- [ ] 5. **Страница входа (web)** — если `GET .../accounts` вернул 200 — над
- [x] 5. **Страница входа (web)** — если `GET .../accounts` вернул 200 — над
обычной формой блок «Быстрый вход (тестовый стенд)» с карточками + поиск.
Клик — моментальный логин через мастер-пароль. На проде блока нет.
- [ ] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса

View file

@ -0,0 +1,122 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
/// Карточка демо-аккаунта с полей, которые отдаёт /api/dev/quick-login/accounts.
export interface QuickLoginAccount {
email: string
fullName: string
role: string | null
organizationName: string | null
isSuperAdmin: boolean
isDemo: boolean
}
interface Props {
/** Вызывается когда пользователь кликнул по карточке. Email → затем
* LoginPage сам делает login(email, '123456'). */
onPick(email: string): void
}
/**
* Блок «Быстрый вход (тестовый стенд)» над формой логина. Показывается
* ТОЛЬКО когда /api/dev/quick-login/accounts вернул 200. На проде этот
* эндпоинт отдаёт 404 — компонент после первого запроса рендерит null,
* ничего не светится.
*/
export function QuickLoginBlock({ onPick }: Props) {
const [accounts, setAccounts] = useState<QuickLoginAccount[] | null>(null)
const [loading, setLoading] = useState(true)
const [q, setQ] = useState('')
useEffect(() => {
let cancelled = false
api
.get<QuickLoginAccount[]>('/api/dev/quick-login/accounts')
.then((r) => { if (!cancelled) setAccounts(r.data) })
.catch(() => { if (!cancelled) setAccounts(null) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
// Отдельный запрос при изменении строки поиска — чтобы серверный ?q=
// работал и всегда отражал серверные ограничения (топ-50, порядок).
useEffect(() => {
if (!accounts) return // не инициализировано — не дёргаемся
if (q === '') return // первая загрузка уже без ?q
const t = setTimeout(() => {
api.get<QuickLoginAccount[]>(`/api/dev/quick-login/accounts?q=${encodeURIComponent(q)}`)
.then((r) => setAccounts(r.data))
.catch(() => { /* молча — не ломаем страницу */ })
}, 200)
return () => clearTimeout(t)
// Намеренно не включаем accounts в deps — он меняется из этого же эффекта.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q])
if (loading) return null
if (!accounts) return null // 404 или ошибка — на проде ничего не рендерим
return (
<div
data-testid="quick-login-block"
className="w-full max-w-md bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl shadow p-5 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<div>
<h2 className="font-semibold text-sm text-amber-900 dark:text-amber-200">
Быстрый вход (тестовый стенд)
</h2>
<p className="text-xs text-amber-800/80 dark:text-amber-200/70">
Кликните по аккаунту — пароль подставится автоматически.
</p>
</div>
</div>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Поиск: email, ФИО, роль, магазин"
className="w-full rounded-md border border-amber-300 dark:border-amber-700 bg-white dark:bg-amber-950/60 px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
{accounts.length === 0 && (
<p className="text-xs text-amber-800/80 dark:text-amber-200/70">
Ничего не нашлось. Попробуйте очистить фильтр.
</p>
)}
<div className="max-h-80 overflow-y-auto space-y-1.5 -mx-1 px-1">
{accounts.map((a) => (
<button
key={a.email}
type="button"
onClick={() => onPick(a.email)}
data-testid={`quick-login-pick-${a.email}`}
className="w-full text-left rounded-md bg-white dark:bg-amber-950/70 hover:bg-amber-100 dark:hover:bg-amber-900 border border-amber-200 dark:border-amber-800 px-3 py-2 transition-colors"
>
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
<span>{a.fullName}</span>
{a.isSuperAdmin && (
<span className="text-[10px] uppercase font-semibold text-purple-700 bg-purple-100 dark:bg-purple-950/70 dark:text-purple-200 rounded px-1.5 py-0.5">
SuperAdmin
</span>
)}
{a.isDemo && !a.isSuperAdmin && (
<span className="text-[10px] uppercase font-semibold text-emerald-700 bg-emerald-100 dark:bg-emerald-950/60 dark:text-emerald-200 rounded px-1.5 py-0.5">
Демо
</span>
)}
</div>
<div className="text-xs text-slate-600 dark:text-slate-400 truncate">
{a.email}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{[a.role, a.organizationName].filter(Boolean).join(' · ') || '—'}
</div>
</button>
))}
</div>
</div>
)
}

View file

@ -3,8 +3,14 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { login } from '@/lib/auth'
import { api } from '@/lib/api'
import { Logo } from '@/components/Logo'
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
import { validateEmail, messages } from '@/lib/validation'
/** Мастер-пароль «быстрого входа» — должен совпадать с server-side
* QuickLoginOptions.MasterPassword. Используется только на стенде;
* в проде блок быстрого входа не показывается, поэтому значение не утекает. */
const QUICK_LOGIN_MASTER_PASSWORD = '123456'
interface MeResp { roles: string[] }
export function LoginPage() {
@ -45,8 +51,36 @@ export function LoginPage() {
}
}
/** Клик по карточке «быстрого входа»: подставляем email + мастер-пароль
* и сразу шлём /connect/token. На проде этот путь недоступен — блок
* QuickLoginBlock рендерится только после 200 от GET /accounts, которого
* в проде нет (404). */
async function handleQuickLogin(pickedEmail: string) {
setEmail(pickedEmail)
setPassword(QUICK_LOGIN_MASTER_PASSWORD)
setEmailErr(null); setPasswordErr(null)
setLoading(true)
setError(null)
try {
await login(pickedEmail, QUICK_LOGIN_MASTER_PASSWORD)
let target = from
try {
const me = (await api.get<MeResp>('/api/me')).data
if (me.roles?.includes('SuperAdmin') && (from === '/' || from === '/dashboard')) {
target = '/super-admin'
}
} catch { /* fallback на from */ }
navigate(target, { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-900 p-4 gap-4">
<QuickLoginBlock onPick={handleQuickLogin} />
<form
onSubmit={handleSubmit}
noValidate