From 026de9f262484b03c024e6d03c40948a6a34d9c3 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 18:07:39 +0500 Subject: [PATCH] =?UTF-8?q?rebrand:=20=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20+=20=D1=82=D0=BE=D1=81=D1=82=20404=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D0=B2=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20(=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20Mac-Claude)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit БЕЗОПАСНОСТЬ: - Страница входа больше НЕ префилит 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 --- deploy/docker-compose.stage.yml | 5 ++ src/food-market.api/Seed/DevDataSeeder.cs | 50 ++++++++++++++----- .../src/components/QuickLoginBlock.tsx | 25 +++++++--- .../src/components/StageBanner.tsx | 23 ++++----- src/food-market.web/src/pages/LoginPage.tsx | 15 ++++-- 5 files changed, 79 insertions(+), 39 deletions(-) diff --git a/deploy/docker-compose.stage.yml b/deploy/docker-compose.stage.yml index 518710e..666a2bf 100644 --- a/deploy/docker-compose.stage.yml +++ b/deploy/docker-compose.stage.yml @@ -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 diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index e4bcf7a..7b80d7d 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -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(); var userMgr = scope.ServiceProvider.GetRequiredService>(); var roleMgr = scope.ServiceProvider.GetRequiredService>(); + var cfg = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService().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 }) { @@ -74,17 +86,29 @@ public async Task StartAsync(CancellationToken ct) var superAdmin = await userMgr.FindByEmailAsync(superAdminEmail); if (superAdmin is null) { - superAdmin = new User + if (string.IsNullOrWhiteSpace(seedPassword)) { - UserName = superAdminEmail, - Email = superAdminEmail, - EmailConfirmed = true, - FullName = "Platform SuperAdmin", - OrganizationId = null, - }; - var result = await userMgr.CreateAsync(superAdmin, "Admin12345!"); - if (result.Succeeded) - await userMgr.AddToRoleAsync(superAdmin, SystemRoles.SuperAdmin); + log.LogWarning("SuperAdmin ({Email}) не сидируется: задайте секрет Seed:SuperAdminPassword " + + "(или переменную окружения SEED_SUPERADMIN_PASSWORD). Дефолтный пароль разрешён только в Development.", + superAdminEmail); + superAdmin = null; + } + else + { + superAdmin = new User + { + 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 { @@ -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); diff --git a/src/food-market.web/src/components/QuickLoginBlock.tsx b/src/food-market.web/src/components/QuickLoginBlock.tsx index 987af09..08c787e 100644 --- a/src/food-market.web/src/components/QuickLoginBlock.tsx +++ b/src/food-market.web/src/components/QuickLoginBlock.tsx @@ -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 { + 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('/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(`/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 — он меняется из этого же эффекта. diff --git a/src/food-market.web/src/components/StageBanner.tsx b/src/food-market.web/src/components/StageBanner.tsx index efcb7df..b162979 100644 --- a/src/food-market.web/src/components/StageBanner.tsx +++ b/src/food-market.web/src/components/StageBanner.tsx @@ -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(() => { - if (cancelled) return - setState('stage') - sessionStorage.setItem(CACHE_KEY, 'stage') - }) - .catch(() => { - if (cancelled) return - setState('prod') - sessionStorage.setItem(CACHE_KEY, 'prod') - }) + // 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(isStage ? 'stage' : 'prod') + sessionStorage.setItem(CACHE_KEY, isStage ? 'stage' : 'prod') + })() return () => { cancelled = true } }, [state]) diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 13e98a0..74f3567 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -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(null) const [emailErr, setEmailErr] = useState(null) const [passwordErr, setPasswordErr] = useState(null) @@ -145,9 +148,11 @@ export function LoginPage() { Забыли пароль?

-

- Dev admin: admin@food-market.local / Admin12345! -

+ {devLocal && ( +

+ Dev admin: admin@food-market.local / Admin12345! +

+ )} )