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. # Quick-login доступен ТОЛЬКО на стенде; на prod — False.
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false} QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456} 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. # OCR invoice: Claude CLI via ssh на dev-vm.
Invoices__ClaudeCli__RemoteHost: ${INVOICE_REMOTE_HOST:-nns@192.168.1.192} Invoices__ClaudeCli__RemoteHost: ${INVOICE_REMOTE_HOST:-nns@192.168.1.192}
Invoices__ClaudeCli__Executable: /home/nns/.local/bin/claude Invoices__ClaudeCli__Executable: /home/nns/.local/bin/claude

View file

@ -5,6 +5,8 @@
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace foodmarket.Api.Seed; namespace foodmarket.Api.Seed;
@ -30,6 +32,16 @@ public async Task StartAsync(CancellationToken ct)
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>(); var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<Role>>(); 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 }) foreach (var role in new[] { SystemRoles.SuperAdmin, SystemRoles.Admin, SystemRoles.Cashier, SystemRoles.Storekeeper })
{ {
@ -74,17 +86,29 @@ public async Task StartAsync(CancellationToken ct)
var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail); var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail);
if (superAdmin is null) if (superAdmin is null)
{ {
superAdmin = new User if (string.IsNullOrWhiteSpace(seedPassword))
{ {
UserName = superAdminEmail, log.LogWarning("SuperAdmin ({Email}) не сидируется: задайте секрет Seed:SuperAdminPassword " +
Email = superAdminEmail, "(или переменную окружения SEED_SUPERADMIN_PASSWORD). Дефолтный пароль разрешён только в Development.",
EmailConfirmed = true, superAdminEmail);
FullName = "Platform SuperAdmin", superAdmin = null;
OrganizationId = null, }
}; else
var result = await userMgr.CreateAsync(superAdmin, "Admin12345!"); {
if (result.Succeeded) superAdmin = new User
await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin); {
UserName = superAdminEmail,
Email = superAdminEmail,
EmailConfirmed = true,
FullName = "Platform SuperAdmin",
OrganizationId = null,
};
var result = await userMgr.CreateAsync(superAdmin, seedPassword);
if (result.Succeeded)
await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin);
else
superAdmin = null;
}
} }
else else
{ {
@ -105,7 +129,7 @@ public async Task StartAsync(CancellationToken ct)
// Admin демо-организации — владелец/директор Demo Market // Admin демо-организации — владелец/директор Demo Market
const string demoAdminEmail = "admin@demo-market.local"; const string demoAdminEmail = "admin@demo-market.local";
var demoAdmin = await userMgr.FindByEmailAsync(demoAdminEmail); var demoAdmin = await userMgr.FindByEmailAsync(demoAdminEmail);
if (demoAdmin is null) if (demoAdmin is null && !string.IsNullOrWhiteSpace(seedPassword))
{ {
demoAdmin = new User demoAdmin = new User
{ {
@ -115,11 +139,11 @@ public async Task StartAsync(CancellationToken ct)
FullName = "Demo Market Admin", FullName = "Demo Market Admin",
OrganizationId = demoOrg.Id, OrganizationId = demoOrg.Id,
}; };
var result = await userMgr.CreateAsync(demoAdmin, "Admin12345!"); var result = await userMgr.CreateAsync(demoAdmin, seedPassword);
if (result.Succeeded) if (result.Succeeded)
await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin); await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin);
} }
else else if (demoAdmin is not null)
{ {
if (!await userMgr.IsInRoleAsync(demoAdmin, SystemRoles.Admin)) if (!await userMgr.IsInRoleAsync(demoAdmin, SystemRoles.Admin))
await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin); await userMgr.AddToRoleAsync(demoAdmin, SystemRoles.Admin);

View file

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

View file

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

View file

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