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!
+
+ )}
)