From e3bc5cacfc9dce4929144770f3453ca033b76267 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:59:07 +0500 Subject: [PATCH] feat(web): super-admin section + setup wizard + auto-redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin. В сайдбаре отдельная группа «Супер-админ» появляется только если me.roles содержит SuperAdmin (UI-guard, серверная авторизация параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]). Страницы: - /super-admin — KPI-дашборд (orgs/users/products/supplies) - /super-admin/organizations — таблица всех org с фильтром Активные/ Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать (модалка с вводом названия), Восстановить, Удалить навсегда (только если в архиве >30 дней + повторное подтверждение). - /super-admin/organizations/new — двух-секционная форма создания (реквизиты + первый админ); в response получаем generatedPassword и показываем в одноразовой модалке с copy-кнопками. - /super-admin/audit-log — таблица журнала с экспортом в CSV. - /super-admin/setup — 3-шаговый wizard (welcome → org → admin → done с временным паролем). Авто-редирект на этот URL для SuperAdmin если /api/super-admin/setup-status возвращает needsSetup=true (в системе ноль организаций) — нельзя пропустить пока не создадут. useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения текущего юзера и его ролей для UI-гейтов. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/food-market.web/src/App.tsx | 10 + .../src/components/AppLayout.tsx | 25 ++- src/food-market.web/src/lib/useMe.ts | 25 +++ .../src/pages/SuperAdminAuditLogPage.tsx | 57 ++++++ .../src/pages/SuperAdminDashboardPage.tsx | 80 ++++++++ .../src/pages/SuperAdminOrgCreatePage.tsx | 142 ++++++++++++++ .../src/pages/SuperAdminOrganizationsPage.tsx | 165 ++++++++++++++++ .../src/pages/SuperAdminSetupPage.tsx | 182 ++++++++++++++++++ 8 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 src/food-market.web/src/lib/useMe.ts create mode 100644 src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx create mode 100644 src/food-market.web/src/pages/SuperAdminDashboardPage.tsx create mode 100644 src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx create mode 100644 src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx create mode 100644 src/food-market.web/src/pages/SuperAdminSetupPage.tsx diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 9057459..40a3d4a 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -3,6 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { LoginPage } from '@/pages/LoginPage' import { DashboardPage } from '@/pages/DashboardPage' import { OnboardingPage } from '@/pages/OnboardingPage' +import { SuperAdminDashboardPage } from '@/pages/SuperAdminDashboardPage' +import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage' +import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage' +import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage' +import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage' import { CountriesPage } from '@/pages/CountriesPage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { PriceTypesPage } from '@/pages/PriceTypesPage' @@ -66,6 +71,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index ccbfef3..7122b29 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -6,7 +6,7 @@ import { logout } from '@/lib/auth' import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Tag, - Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, + Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, ShieldCheck, Building, FileClock, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, } from 'lucide-react' import { Logo } from './Logo' @@ -22,7 +22,7 @@ interface MeResponse { type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean } type NavSection = { group: string; items: NavItem[] } -function buildNav(): NavSection[] { +function buildNav(isSuperAdmin: boolean): NavSection[] { const catalog: NavItem[] = [ { to: '/catalog/products', icon: Package, label: 'Товары' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, @@ -61,6 +61,14 @@ function buildNav(): NavSection[] { { to: '/settings/employees', icon: UserCog, label: 'Сотрудники' }, { to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, ]}, + ...(isSuperAdmin ? [{ + group: 'Супер-админ', + items: [ + { to: '/super-admin', icon: ShieldCheck, label: 'Консоль', end: true }, + { to: '/super-admin/organizations', icon: Building, label: 'Организации' }, + { to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал' }, + ], + }] : []), ] } @@ -71,7 +79,18 @@ export function AppLayout() { staleTime: 5 * 60 * 1000, }) - const nav = buildNav() + const isSuperAdmin = !!me?.roles?.includes('SuperAdmin') + const nav = buildNav(isSuperAdmin) + // Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем + // на setup wizard (нельзя пропустить, пока не создадут первую орг). + const location2 = useLocation() + useEffect(() => { + if (!isSuperAdmin) return + if (location2.pathname.startsWith('/super-admin/setup')) return + api.get<{ needsSetup: boolean }>('/api/super-admin/setup-status') + .then((r) => { if (r.data.needsSetup) window.location.replace('/super-admin/setup') }) + .catch(() => {}) + }, [isSuperAdmin, location2.pathname]) const [drawerOpen, setDrawerOpen] = useState(false) const location = useLocation() diff --git a/src/food-market.web/src/lib/useMe.ts b/src/food-market.web/src/lib/useMe.ts new file mode 100644 index 0000000..8c54760 --- /dev/null +++ b/src/food-market.web/src/lib/useMe.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api' + +export interface MeResponse { + sub: string + name: string + email: string + roles: string[] + orgId: string +} + +/** Текущий залогиненный юзер с ролями. Используется для гейтов + * SuperAdmin-меню и проверок прав в UI (не вместо серверной авторизации). */ +export function useMe() { + return useQuery({ + queryKey: ['me'], + queryFn: async () => (await api.get('/api/me')).data, + staleTime: 5 * 60 * 1000, + }) +} + +export function useIsSuperAdmin(): boolean { + const me = useMe() + return !!me.data?.roles?.includes('SuperAdmin') +} diff --git a/src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx b/src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx new file mode 100644 index 0000000..be2a556 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx @@ -0,0 +1,57 @@ +import { useCatalogList } from '@/lib/useCatalog' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' + +interface AuditRow { + id: string; createdAt: string; superAdminUserId: string + actionType: string; organizationId: string | null; organizationName: string | null + entityType: string | null; entityId: string | null + description: string | null; reason: string | null; ipAddress: string +} + +export function SuperAdminAuditLogPage() { + const list = useCatalogList('/api/super-admin/audit-log') + const exportCsv = () => { + const rows = list.data?.items ?? [] + const header = 'Дата;Действие;Организация;Описание;Причина;IP\n' + const csv = header + rows.map(r => + [r.createdAt, r.actionType, r.organizationName ?? '', r.description ?? '', r.reason ?? '', r.ipAddress] + .map(s => `"${String(s).replace(/"/g, '""')}"`).join(';') + ).join('\n') + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv` + a.click(); URL.revokeObjectURL(url) + } + return ( + + + + } + footer={list.data && list.data.total > 0 && ( + + )}> + r.id} + columns={[ + { header: 'Дата', width: '160px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') }, + { header: 'Действие', width: '140px', cell: (r) => {r.actionType} }, + { header: 'Организация', cell: (r) => r.organizationName ?? '—' }, + { header: 'Описание', cell: (r) => r.description ?? '—' }, + { header: 'Причина', cell: (r) => r.reason ?? '—' }, + { header: 'IP', width: '140px', cell: (r) => {r.ipAddress} }, + ]} + /> + + ) +} diff --git a/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx new file mode 100644 index 0000000..c71ee6a --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminDashboardPage.tsx @@ -0,0 +1,80 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { Building2, Users, Package, ShoppingCart, FileText, Plus } from 'lucide-react' +import { api } from '@/lib/api' +import { PageHeader } from '@/components/PageHeader' + +interface DashboardStats { + totalOrgs: number; activeOrgs: number; archivedOrgs: number + totalUsers: number; activeUsers: number + totalProducts: number; totalSuppliesThisMonth: number +} + +const fmt = new Intl.NumberFormat('ru') + +function Kpi({ icon: Icon, label, value, hint }: { + icon: React.ComponentType<{ className?: string }>; label: string; value: string | number; hint?: string +}) { + return ( +
+
+
+
{label}
+
{value}
+ {hint &&
{hint}
} +
+ +
+
+ ) +} + +export function SuperAdminDashboardPage() { + const { data } = useQuery({ + queryKey: ['/api/super-admin/dashboard'], + queryFn: async () => (await api.get('/api/super-admin/dashboard')).data, + }) + return ( +
+
+ +
+ + + + +
+
+ +
+ +
+
Организации
+
Создание, правка, архивирование
+
+
+ + +
+ +
+
Журнал действий
+
Аудит-лог супер-админа
+
+
+ +
+
+ + Создать организацию + +
+
+
+ ) +} diff --git a/src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx b/src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx new file mode 100644 index 0000000..8c7df3d --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Save, Copy } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Modal } from '@/components/Modal' +import { Field, TextInput, Select } from '@/components/Field' +import { useCountries, useCurrencies } from '@/lib/useLookups' +import { PageHeader } from '@/components/PageHeader' + +export function SuperAdminOrgCreatePage() { + const navigate = useNavigate() + const countries = useCountries() + const currencies = useCurrencies() + const [name, setName] = useState('') + const [countryCode, setCountryCode] = useState('KZ') + const [bin, setBin] = useState('') + const [address, setAddress] = useState('') + const [phone, setPhone] = useState('') + const [email, setEmail] = useState('') + const [defaultCurrencyId, setDefaultCurrencyId] = useState('') + const [adminLast, setAdminLast] = useState('') + const [adminFirst, setAdminFirst] = useState('') + const [adminEmail, setAdminEmail] = useState('') + const [adminPosition, setAdminPosition] = useState('Директор') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState<{ email: string; password: string } | null>(null) + + const onCountryChange = (cc: string) => { + setCountryCode(cc) + const c = countries.data?.find((x) => x.code === cc) + if (c?.defaultCurrencyId) setDefaultCurrencyId(c.defaultCurrencyId) + } + + const submit = async () => { + setError(null); setBusy(true) + try { + const res = await api.post<{ adminEmail: string; adminTempPassword: string; organization: { id: string } }>( + '/api/super-admin/organizations', + { + org: { name, countryCode, bin: bin || null, address: address || null, + phone: phone || null, email: email || null, + defaultCurrencyId: defaultCurrencyId || null, accountOwnerUserId: null }, + adminLastName: adminLast, adminFirstName: adminFirst, + adminEmail, adminPosition: adminPosition || null, + }) + setDone({ email: res.data.adminEmail, password: res.data.adminTempPassword }) + } catch (e) { + const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? (e as Error).message + setError(msg) + } finally { setBusy(false) } + } + + const canSubmit = name.trim() && countryCode && adminLast.trim() && adminFirst.trim() && adminEmail.trim() + + return ( +
+
+ + {error &&
{error}
} +
+

Реквизиты организации

+ + setName(e.target.value)} /> + +
+ + + + + + +
+
+ setBin(e.target.value)} /> + setPhone(e.target.value)} /> +
+ setAddress(e.target.value)} /> + setEmail(e.target.value)} /> +
+ +
+

Главный администратор

+
+ setAdminLast(e.target.value)} /> + setAdminFirst(e.target.value)} /> +
+ + setAdminEmail(e.target.value)} /> + + + setAdminPosition(e.target.value)} /> + +

+ Будет сгенерирован временный пароль и показан один раз — передайте его сотруднику. +

+
+ +
+ + +
+
+ + { setDone(null); navigate('/super-admin/organizations') }} + title="Организация создана" + footer={}> + {done && ( +
+

+ Передайте администратору данные для входа. Это окно показывается один раз. +

+ +
+ + +
+
+ +
+ + +
+
+
+ )} +
+
+ ) +} diff --git a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx new file mode 100644 index 0000000..29080ca --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Plus, Archive, RotateCcw, Trash2, Eye } from 'lucide-react' +import { ListPageShell } from '@/components/ListPageShell' +import { DataTable } from '@/components/DataTable' +import { Pagination } from '@/components/Pagination' +import { SearchBar } from '@/components/SearchBar' +import { Button } from '@/components/Button' +import { Modal } from '@/components/Modal' +import { Field, TextInput } from '@/components/Field' +import { useCatalogList } from '@/lib/useCatalog' +import { api } from '@/lib/api' + +const URL = '/api/super-admin/organizations' + +interface OrgRow { + id: string; name: string; countryCode: string + isActive: boolean; isArchived: boolean; archivedAt: string | null + createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null +} + +export function SuperAdminOrganizationsPage() { + const navigate = useNavigate() + const [archived, setArchived] = useState(false) + const list = useCatalogList(URL, { archived: archived ?? undefined }) + const [archiveOf, setArchiveOf] = useState(null) + const [deleteOf, setDeleteOf] = useState(null) + const [confirmName, setConfirmName] = useState('') + + const archive = async () => { + if (!archiveOf || confirmName !== archiveOf.name) return + await api.post(`${URL}/${archiveOf.id}/archive`, { confirmationName: confirmName }) + setArchiveOf(null); setConfirmName('') + list.refetch() + } + const restore = async (id: string) => { + await api.post(`${URL}/${id}/restore`) + list.refetch() + } + const hardDelete = async () => { + if (!deleteOf || confirmName !== deleteOf.name) return + await api.delete(`${URL}/${deleteOf.id}`, { data: { confirmationName: confirmName } }) + setDeleteOf(null); setConfirmName('') + list.refetch() + } + + const isOver30Days = (iso: string | null) => { + if (!iso) return false + return new Date(iso).getTime() < Date.now() - 30 * 24 * 60 * 60 * 1000 + } + + return ( + <> + + + + + + } + footer={list.data && list.data.total > 0 && ( + + )} + > + r.id} + sortKey={list.sortKey} + sortOrder={list.sortOrder} + onSortChange={list.setSort} + onRowClick={(r) => navigate(`/super-admin/organizations/${r.id}`)} + columns={[ + { header: 'Название', cell: (r) => ( +
+
{r.name}
+
{r.countryCode}
+
+ )}, + { header: 'Создана', width: '140px', cell: (r) => new Date(r.createdAt).toLocaleDateString('ru') }, + { header: 'Сотрудники', width: '110px', className: 'text-right', cell: (r) => r.employeeCount }, + { header: 'Товары', width: '110px', className: 'text-right', cell: (r) => r.productCount }, + { header: 'Last login', width: '140px', cell: (r) => r.lastLoginAt ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' }, + { header: 'Статус', width: '120px', cell: (r) => r.isArchived + ? Архив + : Активна }, + { header: '', width: '160px', cell: (r) => ( +
e.stopPropagation()}> + + {!r.isArchived ? ( + + ) : ( + <> + + + + )} +
+ )}, + ]} + /> +
+ + setArchiveOf(null)} + title="Архивировать организацию" + footer={<> + + + }> + {archiveOf && ( +
+

+ Организация {archiveOf.name} станет недоступной пользователям, но + данные сохранятся. Удалить навсегда можно через 30 дней. +

+ + setConfirmName(e.target.value)} /> + +
+ )} +
+ + setDeleteOf(null)} + title="Удалить организацию навсегда" + footer={<> + + + }> + {deleteOf && ( +
+

+ Все данные организации {deleteOf.name} будут удалены безвозвратно. + Действие необратимо. +

+ + setConfirmName(e.target.value)} /> + +
+ )} +
+ + ) +} diff --git a/src/food-market.web/src/pages/SuperAdminSetupPage.tsx b/src/food-market.web/src/pages/SuperAdminSetupPage.tsx new file mode 100644 index 0000000..864cbe1 --- /dev/null +++ b/src/food-market.web/src/pages/SuperAdminSetupPage.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { ArrowRight, ArrowLeft, Check, Copy } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextInput, Select } from '@/components/Field' +import { useCountries, useCurrencies } from '@/lib/useLookups' + +interface SetupStatus { needsSetup: boolean; orgCount: number } + +export function SuperAdminSetupPage() { + const navigate = useNavigate() + const [step, setStep] = useState(1) + const [name, setName] = useState('') + const [countryCode, setCountryCode] = useState('KZ') + const [defaultCurrencyId, setDefaultCurrencyId] = useState('') + const [bin, setBin] = useState('') + const [phone, setPhone] = useState('') + const [adminLast, setAdminLast] = useState('') + const [adminFirst, setAdminFirst] = useState('') + const [adminEmail, setAdminEmail] = useState('') + const [adminPosition, setAdminPosition] = useState('Директор') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState<{ email: string; password: string } | null>(null) + + const countries = useCountries() + const currencies = useCurrencies() + + useEffect(() => { + // Если уже есть организации — не показываем wizard, выкидываем на дашборд. + api.get('/api/super-admin/setup-status').then((r) => { + if (!r.data.needsSetup) navigate('/super-admin', { replace: true }) + }).catch(() => {}) + }, [navigate]) + + const onCountryChange = (cc: string) => { + setCountryCode(cc) + const c = countries.data?.find((x) => x.code === cc) + if (c?.defaultCurrencyId) setDefaultCurrencyId(c.defaultCurrencyId) + } + + const finish = async () => { + setError(null); setBusy(true) + try { + const res = await api.post<{ adminEmail: string; adminTempPassword: string }>( + '/api/super-admin/organizations', + { + org: { name, countryCode, bin: bin || null, address: null, phone: phone || null, email: null, + defaultCurrencyId: defaultCurrencyId || null, accountOwnerUserId: null }, + adminLastName: adminLast, adminFirstName: adminFirst, + adminEmail, adminPosition: adminPosition || null, + }) + setDone({ email: res.data.adminEmail, password: res.data.adminTempPassword }) + } catch (e) { + const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? (e as Error).message + setError(msg) + } finally { setBusy(false) } + } + + if (done) { + return ( +
+
+
+
+ +
+

Готово!

+

Организация создана. Сохраните данные для входа администратора.

+
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+
+ ) + } + + const canStep2 = name.trim() && countryCode && defaultCurrencyId + const canStep3 = adminLast.trim() && adminFirst.trim() && adminEmail.trim() + + return ( +
+
+
+
Шаг {step} из 3
+
+
+
+
+ + {error &&
{error}
} + + {step === 1 && ( +
+

Добро пожаловать в Food Market

+

+ Этот установщик поможет создать первую организацию и подготовить систему к работе. + Займёт минуту: укажете данные магазина, заведёте первого администратора и можно работать. +

+ +
+ )} + + {step === 2 && ( +
+

Данные организации

+ + setName(e.target.value)} placeholder="Например, Магазин у дома" /> + +
+ + + + + + +
+
+ setBin(e.target.value)} /> + setPhone(e.target.value)} /> +
+
+ + +
+
+ )} + + {step === 3 && ( +
+

Первый администратор

+

+ Этот пользователь получит полный доступ к организации. Временный пароль будет + сгенерирован и показан на следующем шаге. +

+
+ setAdminLast(e.target.value)} /> + setAdminFirst(e.target.value)} /> +
+ + setAdminEmail(e.target.value)} /> + + + setAdminPosition(e.target.value)} /> + +
+ + +
+
+ )} +
+
+ ) +}