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:
parent
35f633e206
commit
6582333def
|
|
@ -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 жёлтая полоса
|
||||
|
|
|
|||
122
src/food-market.web/src/components/QuickLoginBlock.tsx
Normal file
122
src/food-market.web/src/components/QuickLoginBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue