rebrand: безопасность входа + тост 404 на странице входа (по ревью Mac-Claude)

БЕЗОПАСНОСТЬ:
- Страница входа больше НЕ префилит e-mail/пароль dev-админа и не показывает
  подсказку «Dev admin: …» — только в import.meta.env.DEV на localhost
  (на стенде/проде поля пустые).
- DevDataSeeder: пароль SuperAdmin/демо-админа ТОЛЬКО из секрета
  (Seed:SuperAdminPassword / env SEED_SUPERADMIN_PASSWORD). Дефолт «Admin12345!»
  допустим лишь в Development. На stage/prod (ASPNETCORE_ENVIRONMENT=Production)
  без секрета аккаунт с известным паролем НЕ создаётся (warning в лог).
- stage-compose: проброшен Seed__SuperAdminPassword (без дефолта).
- (Пароль существующего SuperAdmin на стенде сменён вручную на сложный и сохранён
  в ~/.fm-watchdog/stage-superadmin-password; quick-login по sentinel/master цел.)

ТОСТ 404:
- StageBanner и QuickLoginBlock пробуют необязательный /api/dev/quick-login/accounts
  через raw fetch (минуя axios-интерсептор) — на проде/без API 404 больше не
  показывается глобальным тостом. На странице входа тостов ошибок нет.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 18:07:39 +05:00
parent 89c5c6df37
commit 026de9f262
5 changed files with 79 additions and 39 deletions

View file

@ -54,6 +54,11 @@ services:
# Quick-login доступен ТОЛЬКО на стенде; на prod — False.
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456}
# Пароль сидируемого SuperAdmin — ТОЛЬКО из секрета, без дефолта. Без него
# на свежей БД (ASPNETCORE_ENVIRONMENT=Production) SuperAdmin не создаётся с
# известным паролем. На существующей БД пароль не трогается (ротация — вручную,
# см. docs/rebrand-all-market-progress.md и ~/.fm-watchdog/stage-superadmin-password).
Seed__SuperAdminPassword: ${SEED_SUPERADMIN_PASSWORD:-}
# OCR invoice: Claude CLI via ssh на dev-vm.
Invoices__ClaudeCli__RemoteHost: ${INVOICE_REMOTE_HOST:-nns@192.168.1.192}
Invoices__ClaudeCli__Executable: /home/nns/.local/bin/claude

View file

@ -5,6 +5,8 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace foodmarket.Api.Seed;
@ -30,6 +32,16 @@ public async Task StartAsync(CancellationToken ct)
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>();
var cfg = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var log = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("DevDataSeeder");
// Пароль сидируемых аккаунтов — ТОЛЬКО из секрета/переменной окружения.
// Дефолт «Admin12345!» допустим исключительно в Development (локальная разработка).
// На stage/prod без секрета аккаунт с известным паролем НЕ создаётся.
string? seedPassword = cfg["Seed:SuperAdminPassword"]
?? Environment.GetEnvironmentVariable("SEED_SUPERADMIN_PASSWORD");
if (string.IsNullOrWhiteSpace(seedPassword) && _env.IsDevelopment())
seedPassword = "Admin12345!";
foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Cashier, SystemRoles.Storekeeper })
{
@ -73,6 +85,15 @@ public async Task StartAsync(CancellationToken ct)
const string superAdminEmail = "admin@food-market.local";
var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail);
if (superAdmin is null)
{
if (string.IsNullOrWhiteSpace(seedPassword))
{
log.LogWarning("SuperAdmin ({Email}) не сидируется: задайте секрет Seed:SuperAdminPassword " +
"(или переменную окружения SEED_SUPERADMIN_PASSWORD). Дефолтный пароль разрешён только в Development.",
superAdminEmail);
superAdmin = null;
}
else
{
superAdmin = new User
{
@ -82,9 +103,12 @@ public async Task StartAsync(CancellationToken ct)
FullName = "Platform SuperAdmin",
OrganizationId = null,
};
var result = await userMgr.CreateAsync(superAdmin, "Admin12345!");
var result = await userMgr.CreateAsync(superAdmin, seedPassword);
if (result.Succeeded)
await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin);
else
superAdmin = null;
}
}
else
{
@ -105,7 +129,7 @@ public async Task StartAsync(CancellationToken ct)
// Admin демо-организации — владелец/директор Demo Market
const string demoAdminEmail = "admin@demo-market.local";
var demoAdmin = await userMgr.FindByEmailAsync(demoAdminEmail);
if (demoAdmin is null)
if (demoAdmin is null && !string.IsNullOrWhiteSpace(seedPassword))
{
demoAdmin = new User
{
@ -115,11 +139,11 @@ public async Task StartAsync(CancellationToken ct)
FullName = "Demo Market Admin",
OrganizationId = demoOrg.Id,
};
var result = await userMgr.CreateAsync(demoAdmin, "Admin12345!");
var result = await userMgr.CreateAsync(demoAdmin, seedPassword);
if (result.Succeeded)
await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin);
}
else
else if (demoAdmin is not null)
{
if (!await userMgr.IsInRoleAsync(demoAdmin, SystemRoles.Admin))
await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin);

View file

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
/// Карточка демо-аккаунта с полей, которые отдаёт /api/dev/quick-login/accounts.
export interface QuickLoginAccount {
@ -28,12 +27,24 @@ export function QuickLoginBlock({ onPick }: Props) {
const [loading, setLoading] = useState(true)
const [q, setQ] = useState('')
// Необязательный стендовый эндпоинт: на проде/без API отдаёт 404 — это НЕ ошибка
// для пользователя. Берём raw fetch, минуя axios-интерсептор (иначе он покажет
// глобальный тост «Не найдено» на странице входа).
async function fetchAccounts(query: string): Promise<QuickLoginAccount[] | null> {
try {
const url = '/api/dev/quick-login/accounts' + (query ? `?q=${encodeURIComponent(query)}` : '')
const r = await fetch(url, { headers: { Accept: 'application/json' } })
if (!r.ok) return null
return (await r.json()) as QuickLoginAccount[]
} catch {
return null
}
}
useEffect(() => {
let cancelled = false
api
.get<QuickLoginAccount[]>('/api/dev/quick-login/accounts')
.then((r) => { if (!cancelled) setAccounts(r.data) })
.catch(() => { if (!cancelled) setAccounts(null) })
fetchAccounts('')
.then((a) => { if (!cancelled) setAccounts(a) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
@ -44,9 +55,7 @@ export function QuickLoginBlock({ onPick }: Props) {
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(() => { /* молча — не ломаем страницу */ })
fetchAccounts(q).then((a) => { if (a) setAccounts(a) })
}, 200)
return () => clearTimeout(t)
// Намеренно не включаем accounts в deps — он меняется из этого же эффекта.

View file

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'
import { api } from '@/lib/api'
/**
* Жёлтая полоса сверху «ТЕСТОВЫЙ СТЕНД — не вводите реальные данные».
@ -24,18 +23,16 @@ export function StageBanner() {
useEffect(() => {
if (state !== 'loading') return
let cancelled = false
api
.get('/api/dev/quick-login/accounts')
.then(() => {
// Raw fetch (минуя axios-интерсептор): на проде/без API эндпоинт 404 — это
// штатный признак «не стенд», а не ошибка. Через axios он бы показал тост.
;(async () => {
let isStage = false
try { isStage = (await fetch('/api/dev/quick-login/accounts', { headers: { Accept: 'application/json' } })).ok }
catch { isStage = false }
if (cancelled) return
setState('stage')
sessionStorage.setItem(CACHE_KEY, 'stage')
})
.catch(() => {
if (cancelled) return
setState('prod')
sessionStorage.setItem(CACHE_KEY, 'prod')
})
setState(isStage ? 'stage' : 'prod')
sessionStorage.setItem(CACHE_KEY, isStage ? 'stage' : 'prod')
})()
return () => { cancelled = true }
}, [state])

View file

@ -22,8 +22,11 @@ export function LoginPage() {
const location = useLocation()
const from = (location.state as { from?: string })?.from ?? '/'
const [email, setEmail] = useState('admin@food-market.local')
const [password, setPassword] = useState('Admin12345!')
// Префилл только в dev-режиме на localhost. На стенде/проде — пустые поля,
// чтобы учётка dev-админа и её пароль не попадали в UI.
const devLocal = import.meta.env.DEV && typeof window !== 'undefined' && window.location.hostname === 'localhost'
const [email, setEmail] = useState(devLocal ? 'admin@food-market.local' : '')
const [password, setPassword] = useState(devLocal ? 'Admin12345!' : '')
const [error, setError] = useState<string | null>(null)
const [emailErr, setEmailErr] = useState<string | null>(null)
const [passwordErr, setPasswordErr] = useState<string | null>(null)
@ -145,9 +148,11 @@ export function LoginPage() {
<a href="/forgot-password" className="text-[var(--color-brand)] hover:underline">Забыли пароль?</a>
</p>
{devLocal && (
<p className="text-xs text-slate-400 text-center">
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
</p>
)}
</form>
</div>
)