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:
parent
89c5c6df37
commit
026de9f262
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 — он меняется из этого же эффекта.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue