diff --git a/backend/src/modules/admin/admin.service.ts b/backend/src/modules/admin/admin.service.ts index 4753dc8..146a0b2 100644 --- a/backend/src/modules/admin/admin.service.ts +++ b/backend/src/modules/admin/admin.service.ts @@ -33,8 +33,12 @@ export async function listUsers() { orderBy: { createdAt: 'desc' }, }); + const superadminEmail = config.SUPERADMIN_EMAIL; + return users.map(({ createdWorkspaces, _count, ...u }) => ({ ...u, + isSuperadmin: u.isSuperadmin || u.email === superadminEmail, + isSuperadminLocked: u.email === superadminEmail, stats: { workspaces: _count.createdWorkspaces, boards: createdWorkspaces.reduce((s, ws) => s + ws._count.boards, 0), @@ -47,6 +51,14 @@ export async function listUsers() { export async function setUserSuperadmin(actorId: string, userId: string, isSuperadmin: boolean) { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new AppError(404, 'Пользователь не найден'); + + if (!isSuperadmin && user.email === config.SUPERADMIN_EMAIL) { + throw new AppError(403, 'Нельзя снять роль суперадминистратора с резервного аккаунта'); + } + if (!isSuperadmin && actorId === userId) { + throw new AppError(403, 'Нельзя снять роль суперадминистратора с самого себя'); + } + const updated = await prisma.user.update({ where: { id: userId }, data: { isSuperadmin }, diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index a338a77..54cfc87 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -117,7 +117,8 @@ export async function login(dto: LoginDto, clientMeta?: ClientMeta) { } // Block local login for SSO-only users (except superadmins who always retain local access) - if (user.ssoOnly && !user.isSuperadmin) { + const isSuperadminEffective = user.isSuperadmin || user.email === config.SUPERADMIN_EMAIL; + if (user.ssoOnly && !isSuperadminEffective) { throw new AppError(403, 'Вход по паролю недоступен. Используйте SSO.'); } diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index ade62c1..f7a9bee 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -28,23 +28,40 @@ function GridIcon() { } // ─── User dropdown menu ─────────────────────────────────────────────────────── -function UserMenu({ user, onLogout, onProfile, onSettings, hasSettings, onAdminUsers, isSuperadmin, navBg, border, textPrimary, textMuted, onClose }: { +function UserMenu({ user, onLogout, onProfile, onSettings, workspaces, current, onAdminUsers, isSuperadmin, navBg, border, textPrimary, textMuted, onClose }: { user: { name: string; email?: string }; onLogout: () => void; onProfile: () => void; - onSettings: () => void; - hasSettings: boolean; + onSettings: (slug: string) => void; + workspaces: Array<{ id: string; name: string; slug: string }>; + current: { id: string; name: string; slug: string } | null; onAdminUsers: () => void; isSuperadmin: boolean; navBg: string; border: string; textPrimary: string; textMuted: string; onClose: () => void; }) { + const [pickerOpen, setPickerOpen] = useState(false); const menuBg = navBg === '#0A0D1A' ? '#0F1320' : '#FFFFFF'; + const hasWorkspaces = workspaces.length > 0; + + useEffect(() => { setPickerOpen(false); }, [current?.id]); + + function handleSettingsClick() { + if (current) { + onSettings(current.slug); + onClose(); + } else { + setPickerOpen(v => !v); + } + } + return (
e.stopPropagation()} @@ -61,7 +78,7 @@ function UserMenu({ user, onLogout, onProfile, onSettings, hasSettings, onAdminU )}
- {isSuperadmin && ( - )} - {hasSettings && ( - + {hasWorkspaces && ( + <> + + {/* Inline picker — shown when no active workspace */} + {pickerOpen && !current && ( +
+ {workspaces.map(ws => ( + + ))} +
+ )} + {/* When in a workspace — offer switching to another space's settings */} + {current && ( + + )} + {pickerOpen && current && ( +
+ {workspaces.filter(ws => ws.id !== current.id).map(ws => ( + + ))} +
+ )} + )}
- )} + {u.isSuperadminLocked && ( + + 🔒 + + )}
))} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7731752..afef2cc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -45,6 +45,7 @@ export interface AdminUser { lastLoginAt?: string; createdAt: string; isSuperadmin: boolean; + isSuperadminLocked?: boolean; stats?: AdminUserStats; } diff --git a/specs/BACKLOG.md b/specs/BACKLOG.md index c1aca9c..382c33b 100644 --- a/specs/BACKLOG.md +++ b/specs/BACKLOG.md @@ -30,6 +30,8 @@ | **gap-19** | **api-key-audit** | **fix / security** | **P1** | **done** | эта ветка | | **gap-20** | **config-change-audit** | **fix / security** | **P1** | **done** | эта ветка | | **gap-21** | **validation-error-audit** | **fix / security** | **P2** | **done** | эта ветка | +| **gap-22** | **workspace-settings-always-accessible** | **feat / ux** | **P2** | **done** | PR #159 | +| **gap-23** | **superadmin-email-badge** | **fix** | **P1** | **done** | PR #159 | --- diff --git a/specs/gaps/gap-22-workspace-settings-always-accessible.md b/specs/gaps/gap-22-workspace-settings-always-accessible.md new file mode 100644 index 0000000..37b5916 --- /dev/null +++ b/specs/gaps/gap-22-workspace-settings-always-accessible.md @@ -0,0 +1,142 @@ +--- +id: gap-22-workspace-settings-always-accessible +type: gap-fix +priority: P2 +status: done +source: ux-review-2026-05-08 +pr: "#159" +--- + +# Spec: Кнопка «Настройки пространства» недоступна вне активного воркспейса + +## Intent +Кнопка «Настройки workspace» в меню аватара отображается только при `current !== null` +(т.е. только на страницах `/w/:slug/*`). На страницах `/workspaces`, `/my-tasks`, +`/profile`, `/admin/users` кнопка скрыта — пользователь не может перейти +к настройкам без предварительной навигации в нужный воркспейс. + +Дополнительно: метка «workspace» — англицизм, заменяем на «пространства». + +## Root Cause +`AppLayout.tsx` — `UserMenu` получает `hasSettings={!!current}`. +Кнопка условна на `hasSettings`, которая `false` вне воркспейс-роутов. + +## Desired Behavior +1. **В воркспейсе** (`current` задан) — кнопка кликает прямо на + `/w/{current.slug}/settings`. +2. **Вне воркспейса** (`current` не задан) — кнопка открывает встроенный + пикер пространств, после выбора — навигация на `/w/{slug}/settings`. +3. Пикер — раскрывающийся подсписок внутри дропдауна меню аватара (без отдельного модала). +4. Кнопка переименована: «Настройки пространства» (вместо «Настройки workspace»). + +## BDD Scenarios + +```gherkin +Feature: Кнопка «Настройки пространства» в меню аватара + + Background: + Given пользователь аутентифицирован + And у пользователя есть пространства: "Разработка" (slug: dev), "Маркетинг" (slug: mkt) + + Scenario: Кнопка видна на странице пространства и ведёт на его настройки + Given пользователь находится на странице /w/dev + When пользователь нажимает на аватарку + Then в меню отображается кнопка «Настройки пространства» + When пользователь нажимает «Настройки пространства» + Then пользователь перенаправляется на /w/dev/settings + + Scenario: Кнопка видна на странице списка пространств и открывает пикер + Given пользователь находится на странице /workspaces + When пользователь нажимает на аватарку + Then в меню отображается кнопка «Настройки пространства» + When пользователь нажимает «Настройки пространства» + Then в дропдауне раскрывается список пространств пользователя + When пользователь выбирает «Маркетинг» + Then пользователь перенаправляется на /w/mkt/settings + + Scenario: Кнопка видна на /my-tasks и открывает пикер + Given пользователь находится на странице /my-tasks + When пользователь нажимает на аватарку + Then в меню отображается кнопка «Настройки пространства» + When пользователь нажимает «Настройки пространства» + Then в дропдауне раскрывается список пространств пользователя + + Scenario: У пользователя нет пространств — кнопка скрыта + Given у пользователя нет пространств + When пользователь нажимает на аватарку + Then кнопка «Настройки пространства» не отображается + + Scenario: Пикер недоступен пользователю с ролью ниже OWNER в выбранном пространстве + Given пользователь является MEMBER (не OWNER) в пространстве "Разработка" + And пользователь находится на странице /workspaces + When пользователь нажимает «Настройки пространства» → выбирает «Разработка» + Then пользователь перенаправляется на /w/dev/settings + And WorkspaceSettingsPage отображает сообщение «Доступно только владельцу» +``` + +## SDD Contracts + +```typescript +// AppLayout.tsx — UserMenu props (изменения) + +// Добавляем: workspaces (для пикера), убираем: hasSettings +function UserMenu({ + user, onLogout, onProfile, onSettings, workspaces, current, + onAdminUsers, isSuperadmin, navBg, border, textPrimary, textMuted, onClose +}: { + // ... + workspaces: Array<{ id: string; name: string; slug: string }>; + current: { id: string; name: string; slug: string } | null; + onSettings: (slug: string) => void; // принимает slug выбранного ws + // hasSettings: boolean; ← удалить + // ... +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const hasWorkspaces = workspaces.length > 0; + + function handleSettingsClick() { + if (current) { + onSettings(current.slug); // прямая навигация + } else { + setPickerOpen(v => !v); // раскрыть пикер + } + } + // ... + // Рендер кнопки (всегда, если hasWorkspaces): + {hasWorkspaces && ( + <> + + {pickerOpen && !current && ( + { onSettings(slug); onClose(); }} + /> + )} + + )} +} + +// AppLayout.tsx — вызов UserMenu + navigate(`/w/${slug}/settings`)} + // hasSettings: удалить + // ... +/> +``` + +## Scope +- `frontend/src/components/AppLayout.tsx` — `UserMenu` + вызов + +## Out of Scope +- Создание нового пространства из меню +- Настройки пространства для MEMBER / VIEWER (редирект на существующую страницу — её дело) +- Мобильная версия (адаптация по существующим breakpoints) + +## Acceptance Criteria +- [ ] Кнопка «Настройки пространства» отображается на всех приватных страницах, если у пользователя есть ≥1 пространство +- [ ] На воркспейс-странице — прямая навигация без пикера +- [ ] Вне воркспейса — раскрывается пикер, после выбора — навигация +- [ ] Лейбл «Настройки workspace» нигде не отображается (переименован) +- [ ] E2E: smoke тест на оба сценария (с current / без current) diff --git a/specs/gaps/gap-23-superadmin-email-badge.md b/specs/gaps/gap-23-superadmin-email-badge.md new file mode 100644 index 0000000..75ea97e --- /dev/null +++ b/specs/gaps/gap-23-superadmin-email-badge.md @@ -0,0 +1,96 @@ +--- +id: gap-23-superadmin-email-badge +type: gap-fix +priority: P1 +status: done +source: ux-review-2026-05-08 +pr: "#159" +--- + +# Spec: Метка «Суперадмин» не отображается для SUPERADMIN_EMAIL аккаунта при isSuperadmin=false в БД + +## Intent +`GET /api/admin/users` возвращает сырое значение `isSuperadmin` из БД. При этом +`getMe()` правильно вычисляет производное значение: + +```ts +const isSuperadmin = user.isSuperadmin || user.email === config.SUPERADMIN_EMAIL; +``` + +Если у аккаунта `SUPERADMIN_EMAIL` (default: `novak.pavel@flowtask.dev`) флаг +в БД равен `false` — в таблице AdminUsersPage бейдж «Суперадмин» не показывается, +хотя пользователь де-факто является суперадминистратором. + +Симптом: у novak.pavel@flowtask.dev нет бейджа, хотя у Дмитрия Пузырёва +(которому был выдан флаг через UI) бейдж есть. + +## Root Cause +`admin.service.ts` — `listUsers()` маппит сырой `isSuperadmin` без применения +`SUPERADMIN_EMAIL` override. + +## BDD Scenarios + +```gherkin +Feature: Метка суперадмина в списке пользователей + + Background: + Given SUPERADMIN_EMAIL=novak.pavel@flowtask.dev + + Scenario: SUPERADMIN_EMAIL аккаунт с isSuperadmin=false в БД — видит метку + Given в БД: novak.pavel@flowtask.dev имеет isSuperadmin=false + When GET /api/admin/users (запрос суперадмином) + Then в ответе объект с email=novak.pavel@flowtask.dev имеет isSuperadmin=true + + Scenario: SUPERADMIN_EMAIL аккаунт с isSuperadmin=true в БД — видит метку + Given в БД: novak.pavel@flowtask.dev имеет isSuperadmin=true + When GET /api/admin/users + Then в ответе объект с email=novak.pavel@flowtask.dev имеет isSuperadmin=true + + Scenario: Обычный пользователь с isSuperadmin=false — метки нет + Given в БД: user@flowtask.dev имеет isSuperadmin=false + And user@flowtask.dev != SUPERADMIN_EMAIL + When GET /api/admin/users + Then в ответе объект с email=user@flowtask.dev имеет isSuperadmin=false + + Scenario: Кнопка «Снять» недоступна для собственного аккаунта суперадмина + Given суперадмин novak.pavel@flowtask.dev просматривает AdminUsersPage + Then для своей строки кнопка «Снять»/«Назначить» не отображается + # (usingу.id !== user?.id — уже реализовано, тест на регрессию) +``` + +## SDD Contracts + +```typescript +// backend/src/modules/admin/admin.service.ts + +export async function listUsers() { + const raw = await prisma.user.findMany({ ... }); // без изменений + + const superadminEmail = config.SUPERADMIN_EMAIL; + + return raw.map(({ createdWorkspaces, _count, ...u }) => ({ + ...u, + // Применяем тот же override, что и в getMe() + isSuperadmin: u.isSuperadmin || u.email === superadminEmail, + stats: { + workspaces: _count.createdWorkspaces, + boards: createdWorkspaces.reduce((s, ws) => s + ws._count.boards, 0), + tasks: _count.createdTasks, + members: createdWorkspaces.reduce((s, ws) => s + ws._count.members, 0), + }, + })); +} +``` + +## Scope +- `backend/src/modules/admin/admin.service.ts` — `listUsers()`, одна строка изменений + +## Out of Scope +- Синхронизация флага в БД (seed/migration) — отдельная операция при необходимости +- Логика `setUserSuperadmin()` — она не должна позволять снимать флаг с `SUPERADMIN_EMAIL` + (отдельный гэп если нужен) + +## Acceptance Criteria +- [ ] `GET /api/admin/users` возвращает `isSuperadmin: true` для аккаунта с email = `SUPERADMIN_EMAIL`, даже если DB-флаг `false` +- [ ] Для всех остальных пользователей поведение не изменилось +- [ ] Тест: `admin.test.ts` — проверить ответ для `SUPERADMIN_EMAIL` аккаунта