diff --git a/frontend/src/pages/WorkspaceSettingsPage.tsx b/frontend/src/pages/WorkspaceSettingsPage.tsx index a3a1779..015ed32 100644 --- a/frontend/src/pages/WorkspaceSettingsPage.tsx +++ b/frontend/src/pages/WorkspaceSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { message } from 'antd'; import { useThemeStore } from '../store/theme.store'; @@ -169,25 +169,48 @@ export default function WorkspaceSettingsPage() { const [creatingWf, setCreatingWf] = useState(false); const [editingWfId, setEditingWfId] = useState(null); + // Data loading state + const [loadingData, setLoadingData] = useState(false); + const [loadError, setLoadError] = useState(null); + const wsId = workspace?.id; const wsName = workspace?.name; const wsDescription = workspace?.description; const wsIsPrivate = workspace?.isPrivate; + const loadWorkspaceData = useCallback(async (id: string) => { + setLoadingData(true); + setLoadError(null); + try { + const [m, l, wfs] = await Promise.all([ + workspacesApi.listMembers(id), + labelsApi.listLabels(id), + wfApi.listWorkflows(id), + ]); + setMembers(m); setLabels(l); setWorkflows(wfs); + } catch { + setLoadError('Не удалось загрузить данные — попробуйте обновить страницу'); + } finally { + setLoadingData(false); + } + }, []); + useEffect(() => { if (workspaces.length === 0) load(); }, [workspaces.length, load]); + + // Sync form fields from store (does not trigger API reload) useEffect(() => { - if (!wsId) return; setName(wsName ?? ''); setDescription(wsDescription ?? ''); setIsPrivate(wsIsPrivate ?? false); - Promise.all([ - workspacesApi.listMembers(wsId), - labelsApi.listLabels(wsId), - wfApi.listWorkflows(wsId), - ]).then(([m, l, wfs]) => { setMembers(m); setLabels(l); setWorkflows(wfs); }).catch(() => {}); - }, [wsId, wsName, wsDescription, wsIsPrivate]); - - const myRole = members.find((m) => m.userId === currentUser?.id)?.role; + }, [wsName, wsDescription, wsIsPrivate]); + + // Load workspace data only when workspace identity changes + useEffect(() => { + if (!wsId) return; + loadWorkspaceData(wsId); + }, [wsId, loadWorkspaceData]); + + const myRole = loadingData ? undefined : members.find((m) => m.userId === currentUser?.id)?.role; const isOwner = myRole === 'OWNER'; if (!workspace) return null; @@ -419,7 +442,95 @@ export default function WorkspaceSettingsPage() { ); - const renderWorkflows = () => ( + const renderWorkflows = () => { + if (loadingData) return ( +
+
+
+

Workflows

+ Управляйте статусами и переходами для ваших досок +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + + if (loadError) return ( +
+
+
+

Workflows

+
+
+
+ {loadError} + +
+
+ ); + + if (!isOwner) return ( +
+
+
+

Workflows

+ Управляйте статусами и переходами для ваших досок +
+
+
+ Редактирование workflow доступно только владельцу воркспейса +
+
+ {workflows.map((wf) => { + const modeColor = wf.mode === 'FORWARD_ONLY' ? '#22C55E' : wf.mode === 'BIDIRECTIONAL' ? '#4F6EF7' : '#F59E0B'; + return ( +
+
+
+
+ {wf.name} + {wf.isDefault && ( + По умолчанию + )} + + {WF_MODE_LABEL[wf.mode]} + +
+
+ {(wf.statuses ?? []).map((s, i) => ( + + + + {s.name} + + {i < (wf.statuses ?? []).length - 1 && ( + + + + )} + + ))} +
+
+
+
+ ); + })} + {workflows.length === 0 && Нет workflow} +
+
+ ); + + return (
@@ -508,7 +619,8 @@ export default function WorkspaceSettingsPage() { {workflows.length === 0 && Нет workflow}
- ); + ); + }; const renderLabels = () => (
@@ -636,7 +748,7 @@ export default function WorkspaceSettingsPage() { // ─── Layout ──────────────────────────────────────────────────────────────── return (
- + {/* Sidebar */}
diff --git a/specs/BACKLOG.md b/specs/BACKLOG.md index 2f56764..307f044 100644 --- a/specs/BACKLOG.md +++ b/specs/BACKLOG.md @@ -1,83 +1,95 @@ # FlowTask — Specs Backlog -Консолидированный бэклог: активные гепы + out-of-scope из всех спек. -Источник: реверс-инжиниринг кодовой базы, май 2026. +Консолидированный бэклог: открытые гепы + out-of-scope из всех спек. +Последнее обновление: май 2026. Верифицировано по git-истории и кодовой базе. --- -## Активные гепы +## Статус всех гепов + +| # | Файл | Тип | Приоритет | Статус | PR | +|---|------|-----|-----------|--------|----| +| gap-01 | my-tasks-drawer | fix | P1 | superseded → gap-11 | — | +| gap-02 | board-filters-server | fix | P1 | **done** | #133 | +| gap-03 | label-idor | fix / security | P1 | **done** | code | +| gap-04 | assignee-validation | fix | P2 | **done** | #134 | +| gap-05 | pagination | fix | P2 | **done** | code | +| gap-06 | comment-limits | fix | P2 | **done** | #137 | +| gap-07 | subtree-depth | fix | P2 | **done** | #138 | +| gap-08 | notifications | feat | P3 | **done** | #139 | +| gap-09 | global-search | feat | P3 | **done** | #140 | +| gap-10 | bulk-operations | feat | P3 | **done** | #141 | +| gap-11 | my-tasks-accordion | feat | P2 | **done** | #145 | +| **gap-12** | **workflow-settings-unlocked** | **fix** | **P1** | **draft** | — | +| **gap-13** | **2fa-totp** | **feat** | **P2** | **draft** | — | -### P1 — Критично (безопасность + корректность данных) +--- + +## Открытые гепы + +### P1 — Критично -| # | Файл | Тип | Что сломано | -|---|------|-----|-------------| -| gap-03 | [gap-03-label-idor.md](gaps/gap-03-label-idor.md) | fix / **security** | IDOR: метка из чужого workspace привязывается к задаче | -| gap-02 | [gap-02-board-filters-server.md](gaps/gap-02-board-filters-server.md) | fix | Фильтры работают только на первых 100 задачах; >100 — неверные результаты | +#### gap-12 — Workflow Settings ghost-lock -### P2 — Важно (функциональные дыры, деградация UX) +[specs/gaps/gap-12-workflow-settings-unlocked.md](gaps/gap-12-workflow-settings-unlocked.md) -| # | Файл | Тип | Что сломано / чего не хватает | -|---|------|-----|-------------------------------| -| gap-11 | [gap-11-my-tasks-accordion.md](gaps/gap-11-my-tasks-accordion.md) | feat | **approved** — аккордеон в My Tasks вместо навигации на доску | -| gap-05 | [gap-05-pagination.md](gaps/gap-05-pagination.md) | fix | Нет пагинации: boards лимит 100, comments без лимита (OOM риск) | -| gap-04 | [gap-04-assignee-validation.md](gaps/gap-04-assignee-validation.md) | fix | Можно назначить задачу любому UUID, не только участнику workspace | -| gap-07 | [gap-07-subtree-depth.md](gaps/gap-07-subtree-depth.md) | fix | Рекурсивный subtree без depth limit — потенциальный stack overflow | -| gap-06 | [gap-06-comment-limits.md](gaps/gap-06-comment-limits.md) | fix | Нет max length на comment.body и checklist items — OOM при fetch | +`.catch(() => {})` при загрузке участников страницы настроек: OWNER видит интерфейс read-only, не может редактировать workflow. +Фикс: явная обработка ошибки + skeleton + `isOwner` вычисляется только после `loadingData = false`. -### P3 — Желательно (новые фичи) +### P2 — Важно -| # | Файл | Тип | Что нужно | -|---|------|-----|-----------| -| gap-08 | [gap-08-notifications.md](gaps/gap-08-notifications.md) | feat | Уведомления: in-app (колокольчик) + email (при назначении, комментарии) | -| gap-09 | [gap-09-global-search.md](gaps/gap-09-global-search.md) | feat | Глобальный поиск Cmd+K по задачам всех workspace | -| gap-10 | [gap-10-bulk-operations.md](gaps/gap-10-bulk-operations.md) | feat | Массовые операции: bulk assign / priority / move / delete | +#### gap-13 — 2FA/TOTP (SSO-режим) -> gap-01 superseded by gap-11. +[specs/gaps/gap-13-2fa-totp.md](gaps/gap-13-2fa-totp.md) + +Проверка второго фактора через `amr` claim в OIDC-токене от Avanpost/Keycloak. +FlowTask не хранит TOTP-секреты — делегирует всё IdP. +Включает: `requireMfa` на уровне workspace, grace period, `workspaceMfaGuard` middleware. --- ## Out-of-Scope — приоритизированный бэклог Собрано из `## Out of Scope` всех спек (`existing/` + `gaps/`). +Помечены реализованные позиции. ### P1 — Закрыть в ближайших итерациях -| Пункт | Источник | Почему P1 | -|-------|----------|-----------| -| Rate limiting на API-ключи | [12-integrations](existing/12-integrations.md) | Интеграционные ключи без лимита = DoS-вектор | -| Упоминания `@user` в комментариях | [07-comments](existing/07-comments.md), [gap-08](gaps/gap-08-notifications.md) | Базовый collaboration-паттерн; нужен до уведомлений | -| Deep link на задачу (URL при открытии drawer) | [gap-01](gaps/gap-01-my-tasks-drawer.md) | Без deep link нельзя шарить задачу ссылкой | -| Сохранение фильтров в URL | [gap-02](gaps/gap-02-board-filters-server.md) | После серверных фильтров (gap-02) — естественное следствие | +| Пункт | Источник | Статус | Заметка | +|-------|----------|--------|---------| +| Упоминания `@user` в комментариях | [07-comments](existing/07-comments.md), [gap-08](gaps/gap-08-notifications.md) | **done** (PR #135) | Реализовано в рамках activity-feed PR | +| Rate limiting на API-ключи | [12-integrations](existing/12-integrations.md) | open | Интеграционные ключи без лимита — DoS-вектор | +| Deep link на задачу (URL при открытии drawer) | [gap-01](gaps/gap-01-my-tasks-drawer.md) | **done** (gap-11) | `?from=my-tasks&open=` реализован в gap-11 | +| Сохранение фильтров в URL | [gap-02](gaps/gap-02-board-filters-server.md) | open | После gap-02; естественное следствие | ### P2 — Следующая волна фич -| Пункт | Источник | Заметка | -|-------|----------|---------| -| Webhooks (outbound events при изменении задач) | [12-integrations](existing/12-integrations.md) | Нужны для интеграций с CI/CD, Slack, внешними системами | -| 2FA / TOTP | [01-auth](existing/01-auth.md) | Требование enterprise-клиентов | -| Зависимости между задачами (blocking/blocked-by) | [04-tasks](existing/04-tasks.md) | Есть спрос в PM-инструментах; нужна модель в schema | -| Фильтрация в Roadmap view | [gap-02](gaps/gap-02-board-filters-server.md) | Отдельный endpoint; после gap-02 | -| Triggered actions при смене статуса (webhooks) | [05-workflows](existing/05-workflows.md) | Зависит от webhooks выше | -| Reorder пунктов чеклиста (drag-n-drop) | [08-checklists](existing/08-checklists.md) | Базовая UX-потребность | -| WebSocket real-time доставка in-app | [gap-08](gaps/gap-08-notifications.md) | После polling-версии уведомлений (gap-08) | -| Push-уведомления (browser/mobile) | [gap-08](gaps/gap-08-notifications.md) | После in-app (gap-08) | +| Пункт | Источник | Статус | Заметка | +|-------|----------|--------|---------| +| Webhooks (outbound events при изменении задач) | [12-integrations](existing/12-integrations.md) | open | Нужны для CI/CD, Slack, внешних систем | +| Triggered actions при смене статуса | [05-workflows](existing/05-workflows.md) | open | Зависит от webhooks | +| Зависимости между задачами (blocking/blocked-by) | [04-tasks](existing/04-tasks.md) | open | Нужна модель в schema | +| Reorder пунктов чеклиста (drag-n-drop) | [08-checklists](existing/08-checklists.md) | open | Базовая UX-потребность | +| Фильтрация в Roadmap view | [gap-02](gaps/gap-02-board-filters-server.md) | open | Отдельный endpoint; после gap-02 | +| WebSocket real-time уведомления | [gap-08](gaps/gap-08-notifications.md) | open | После polling-версии (gap-08 done) | +| Push-уведомления (browser) | [gap-08](gaps/gap-08-notifications.md) | open | После WebSocket | ### P3 — Среднесрочный roadmap -| Пункт | Источник | Заметка | -|-------|----------|---------| -| Rich text / Markdown в комментариях и описаниях | [07-comments](existing/07-comments.md), [gap-06](gaps/gap-06-comment-limits.md) | Требует изменение модели хранения + рендерер | -| Вложения / файлы в комментариях | [07-comments](existing/07-comments.md), [13-feedback](existing/13-feedback.md) | S3/MinIO; отдельная архитектурная задача | -| Export audit log в CSV | [09-history](existing/09-history.md) | Нужен для compliance | -| Экспорт доски в CSV/PDF | [03-boards](existing/03-boards.md) | Частый запрос | -| OAuth (GitHub, Google) | [01-auth](existing/01-auth.md) | Упрощает onboarding | -| Cursor-based пагинация на всех эндпоинтах | [gap-05](gaps/gap-05-pagination.md) | После offset-пагинации (gap-05) | -| Поиск по комментариям | [gap-09](gaps/gap-09-global-search.md) | После глобального поиска (gap-09) | -| Bulk export задач | [gap-10](gaps/gap-10-bulk-operations.md) | После bulk operations (gap-10) | -| Bulk add label | [gap-10](gaps/gap-10-bulk-operations.md) | После bulk operations (gap-10) | -| Дайджест email (еженедельный) | [gap-08](gaps/gap-08-notifications.md) | После email-уведомлений (gap-08) | -| Редактирование полей из аккордеона My Tasks | [gap-11](gaps/gap-11-my-tasks-accordion.md) | После gap-11 | -| Мобильный вариант My Tasks аккордеона | [gap-11](gaps/gap-11-my-tasks-accordion.md) | Адаптив; отдельный дизайн | +| Пункт | Источник | Статус | Заметка | +|-------|----------|--------|---------| +| Rich text / Markdown в комментариях и описаниях | [07-comments](existing/07-comments.md), [gap-06](gaps/gap-06-comment-limits.md) | open | Меняет модель хранения + рендерер | +| Вложения / файлы в комментариях | [07-comments](existing/07-comments.md), [13-feedback](existing/13-feedback.md) | open | S3/MinIO; отдельная архитектурная задача | +| Export audit log в CSV | [09-history](existing/09-history.md) | open | Нужен для compliance | +| Экспорт доски в CSV/PDF | [03-boards](existing/03-boards.md) | open | Частый запрос | +| Cursor-based пагинация | [gap-05](gaps/gap-05-pagination.md) | open | После offset-пагинации (gap-05 done) | +| Поиск по комментариям | [gap-09](gaps/gap-09-global-search.md) | open | После глобального поиска (gap-09 done) | +| Bulk export задач | [gap-10](gaps/gap-10-bulk-operations.md) | open | После bulk operations (gap-10 done) | +| Bulk add label | [gap-10](gaps/gap-10-bulk-operations.md) | open | После bulk operations (gap-10 done) | +| Дайджест email (еженедельный) | [gap-08](gaps/gap-08-notifications.md) | open | После email-уведомлений (gap-08 done) | +| OAuth (GitHub, Google) | [01-auth](existing/01-auth.md) | open | Упрощает onboarding | +| Редактирование полей из аккордеона My Tasks | [gap-11](gaps/gap-11-my-tasks-accordion.md) | open | После gap-11 done | +| Мобильный вариант My Tasks аккордеона | [gap-11](gaps/gap-11-my-tasks-accordion.md) | open | Отдельный дизайн | ### P4 — Долгосрочный roadmap / решение не принято @@ -87,32 +99,28 @@ | Fuzzy search | [gap-09](gaps/gap-09-global-search.md) | После Elasticsearch | | Публичные воркспейсы (приглашение по ссылке) | [02-workspaces](existing/02-workspaces.md) | Меняет модель доступа | | Вложенные воркспейсы | [02-workspaces](existing/02-workspaces.md) | Существенное изменение схемы | -| Перенос доски между воркспейсами | [02-workspaces](existing/02-workspaces.md) | Нужна миграция данных (issueKeys) | -| Блокировка пользователей (без удаления) | [11-admin](existing/11-admin.md) | Требует флаг isActive в User | +| Перенос доски между воркспейсами | [02-workspaces](existing/02-workspaces.md) | Cascading migration issueKeys | +| Блокировка пользователей (без удаления) | [11-admin](existing/11-admin.md) | Флаг isActive в User | | Удаление пользователей | [11-admin](existing/11-admin.md) | Cascading delete — осторожно | | Повторяющиеся задачи | [04-tasks](existing/04-tasks.md) | Сложная логика расписания | | Вложение задачи из другой доски | [04-tasks](existing/04-tasks.md) | Cross-board materialized path | -| Реакции на комментарии (👍 и т.д.) | [07-comments](existing/07-comments.md) | Nice-to-have | +| Реакции на комментарии (👍) | [07-comments](existing/07-comments.md) | Nice-to-have | | Иерархия меток / папки | [06-labels](existing/06-labels.md) | Usability улучшение | | Cross-workspace labels | [gap-03](gaps/gap-03-label-idor.md) | Не в концепции продукта | -| Приглашения по ссылке без email | [01-auth](existing/01-auth.md) | Безопасность vs удобство | | OAuth apps / scopes для API-ключей | [12-integrations](existing/12-integrations.md) | Нужна отдельная модель permissions | | Статус обращения feedback (открыт/закрыт) | [13-feedback](existing/13-feedback.md) | Нужен feedback workflow | | Просмотр своих обращений в FlowTask | [13-feedback](existing/13-feedback.md) | После статусов обращений | +| SMS 2FA | [gap-13](gaps/gap-13-2fa-totp.md) | Не в scope (только TOTP) | +| WebAuthn / FIDO2 | [gap-13](gaps/gap-13-2fa-totp.md) | Долгосрочно | --- -## Сводка по компонентам - -| Компонент | Активных гепов | Out-of-scope (todo) | -|-----------|---------------|---------------------| -| Tasks / Board | 4 (gap-02,05,07,10) | 6+ | -| My Tasks | 2 (gap-01→11, gap-11) | 3 | -| Auth | — | 3 (2FA, OAuth, invite) | -| Labels | 1 (gap-03) | 2 | -| Comments | 1 (gap-06) | 4 (mentions, RT, files, reactions) | -| Notifications | 1 (gap-08) | 3 (push, WS, digest) | -| Search | 1 (gap-09) | 2 (comments, fuzzy) | -| Integrations | — | 3 (webhooks, rate-limit, scopes) | -| Admin | — | 2 (block, delete) | -| Workspaces | — | 3 | +## Сводка + +| Раздел | Открыто | Закрыто/Done | +|--------|---------|--------------| +| Активные гепы (gap-01..13) | **2** (gap-12, gap-13) | 11 | +| OoS P1 | **2** (rate limit, filter URL) | 2 | +| OoS P2 | **7** | — | +| OoS P3 | **13** | — | +| OoS P4 | **16** | — | diff --git a/specs/gaps/gap-02-board-filters-server.md b/specs/gaps/gap-02-board-filters-server.md index c0f6b26..6a9d0c1 100644 --- a/specs/gaps/gap-02-board-filters-server.md +++ b/specs/gaps/gap-02-board-filters-server.md @@ -2,7 +2,7 @@ id: gap-02-board-filters-server type: gap-fix priority: P1 -status: draft +status: done --- # Spec: FilterBar — перенести фильтрацию на сервер diff --git a/specs/gaps/gap-03-label-idor.md b/specs/gaps/gap-03-label-idor.md index 092bb4b..8fbb1a4 100644 --- a/specs/gaps/gap-03-label-idor.md +++ b/specs/gaps/gap-03-label-idor.md @@ -2,7 +2,7 @@ id: gap-03-label-idor type: gap-fix priority: P1 -status: draft +status: done --- # Spec: IDOR — валидация принадлежности метки к воркспейсу задачи diff --git a/specs/gaps/gap-04-assignee-validation.md b/specs/gaps/gap-04-assignee-validation.md index be408f4..7a4eb54 100644 --- a/specs/gaps/gap-04-assignee-validation.md +++ b/specs/gaps/gap-04-assignee-validation.md @@ -2,7 +2,7 @@ id: gap-04-assignee-validation type: gap-fix priority: P2 -status: draft +status: done --- # Spec: Валидация assigneeId — только участники воркспейса diff --git a/specs/gaps/gap-05-pagination.md b/specs/gaps/gap-05-pagination.md index 488ede9..bdc267e 100644 --- a/specs/gaps/gap-05-pagination.md +++ b/specs/gaps/gap-05-pagination.md @@ -2,7 +2,7 @@ id: gap-05-pagination type: gap-fix priority: P2 -status: draft +status: done --- # Spec: Пагинация на всех list-эндпоинтах diff --git a/specs/gaps/gap-06-comment-limits.md b/specs/gaps/gap-06-comment-limits.md index bf1355d..effab29 100644 --- a/specs/gaps/gap-06-comment-limits.md +++ b/specs/gaps/gap-06-comment-limits.md @@ -2,7 +2,7 @@ id: gap-06-comment-limits type: gap-fix priority: P2 -status: draft +status: done --- # Spec: Ограничения длины для комментариев и чеклистов diff --git a/specs/gaps/gap-07-subtree-depth.md b/specs/gaps/gap-07-subtree-depth.md index 2667b07..1a67c1a 100644 --- a/specs/gaps/gap-07-subtree-depth.md +++ b/specs/gaps/gap-07-subtree-depth.md @@ -2,7 +2,7 @@ id: gap-07-subtree-depth type: gap-fix priority: P2 -status: draft +status: done --- # Spec: Лимит глубины рекурсивной выборки подзадач diff --git a/specs/gaps/gap-08-notifications.md b/specs/gaps/gap-08-notifications.md index 2e26d39..964639c 100644 --- a/specs/gaps/gap-08-notifications.md +++ b/specs/gaps/gap-08-notifications.md @@ -2,7 +2,7 @@ id: gap-08-notifications type: gap-feat priority: P3 -status: draft +status: done --- # Spec: Уведомления (In-app + Email) diff --git a/specs/gaps/gap-09-global-search.md b/specs/gaps/gap-09-global-search.md index a238699..45758ca 100644 --- a/specs/gaps/gap-09-global-search.md +++ b/specs/gaps/gap-09-global-search.md @@ -2,7 +2,7 @@ id: gap-09-global-search type: gap-feat priority: P3 -status: draft +status: done --- # Spec: Глобальный поиск по задачам diff --git a/specs/gaps/gap-10-bulk-operations.md b/specs/gaps/gap-10-bulk-operations.md index c5e9290..886dd2a 100644 --- a/specs/gaps/gap-10-bulk-operations.md +++ b/specs/gaps/gap-10-bulk-operations.md @@ -2,7 +2,7 @@ id: gap-10-bulk-operations type: gap-feat priority: P3 -status: draft +status: done --- # Spec: Массовые операции над задачами diff --git a/specs/gaps/gap-11-my-tasks-accordion.md b/specs/gaps/gap-11-my-tasks-accordion.md index 4749df1..1c0d283 100644 --- a/specs/gaps/gap-11-my-tasks-accordion.md +++ b/specs/gaps/gap-11-my-tasks-accordion.md @@ -2,7 +2,7 @@ id: gap-11-my-tasks-accordion type: gap-feat priority: P2 -status: approved +status: done --- # Spec: My Tasks — аккордеон с основной информацией по задаче diff --git a/specs/gaps/gap-12-workflow-settings-unlocked.md b/specs/gaps/gap-12-workflow-settings-unlocked.md new file mode 100644 index 0000000..b06e7a7 --- /dev/null +++ b/specs/gaps/gap-12-workflow-settings-unlocked.md @@ -0,0 +1,176 @@ +--- +id: gap-12-workflow-settings-unlocked +type: gap-fix +priority: P1 +status: draft +--- + +# Spec: Workflow Settings — страница настроек заблокирована (ghost-lock) + +## Intent +Страница `/settings?tab=workflows` отображается как заблокированная для OWNER'а воркспейса из-за silent `.catch(() => {})` при загрузке участников: ошибка API скрывается, `members` остаётся пустым, `isOwner` = false навсегда. + +## Root Cause + +```typescript +// WorkspaceSettingsPage.tsx:183–187 +Promise.all([ + workspacesApi.listMembers(wsId), + labelsApi.listLabels(wsId), + wfApi.listWorkflows(wsId), +]).then(([m, l, wfs]) => { setMembers(m); setLabels(l); setWorkflows(wfs); }) + .catch(() => {}); // ← молчаливо проглатывает ошибку + +// :190–191 +const myRole = members.find((m) => m.userId === currentUser?.id)?.role; +const isOwner = myRole === 'OWNER'; // ← false когда members = [] +``` + +При сетевой ошибке / 403 / 500 `members` остаётся `[]`. +`members.find(...)` возвращает `undefined` → `isOwner = false` → весь tab workflows рендерится read-only без единого сообщения об ошибке. + +**Вторичная гипотеза:** `currentUser?.id` ещё не заполнен в момент render (AuthContext async). Если `currentUser` разрешается позже отдельным setState, `isOwner` фиксируется в `false` до следующего render цикла. + +## BDD Scenarios + +```gherkin +Feature: Workflow Settings — доступность управления для OWNER + + Background: + Given я авторизован как пользователь с ролью OWNER в воркспейсе + And я нахожусь на /w//settings?tab=workflows + + Scenario: страница загружается успешно — кнопки активны + When API /members, /labels, /workflows отвечают 200 + And данные загружены + Then кнопка "Создать workflow" активна и кликабельна + And у каждого workflow есть активные кнопки "Редактировать" и "Удалить" + + Scenario: страница в процессе загрузки — skeleton вместо заблокированных контролов + When запросы к API ещё выполняются + Then видны skeleton-индикаторы загрузки + And кнопки управления отсутствуют (не показываются disabled) + + Scenario: сетевая ошибка или 500 — показывается сообщение с retry + When API /members возвращает 500 или сетевую ошибку + Then показывается сообщение "Не удалось загрузить данные — попробуйте обновить страницу" + And кнопка "Обновить" перезапускает запрос + And кнопок редактирования нет (не можем определить роль) + + Scenario: VIEWER открывает вкладку workflows + Given я авторизован с ролью VIEWER в воркспейсе + When страница загружена успешно + Then кнопка "Создать workflow" отсутствует + And у каждого workflow нет кнопок редактирования + And показывается подсказка "Редактирование workflow доступно только владельцу воркспейса" + + Scenario: MEMBER открывает вкладку workflows + Given я авторизован с ролью MEMBER в воркспейсе + When страница загружена успешно + Then кнопка "Создать workflow" отсутствует + And у каждого workflow нет кнопок редактирования + And показывается подсказка "Редактирование workflow доступно только владельцу воркспейса" + + Scenario: редактирование workflow работает end-to-end + Given страница загружена и isOwner = true + When я нажимаю "Редактировать" на workflow "Default" + Then открывается WorkflowEditor + And все поля доступны для ввода + When я меняю название и сохраняю + Then workflow обновляется в списке без перезагрузки страницы +``` + +## SDD Contracts + +```typescript +// WorkspaceSettingsPage.tsx — изменения + +// 1. Добавить состояние загрузки и ошибки +const [loadError, setLoadError] = useState(null); +const [loadingData, setLoadingData] = useState(true); + +// 2. Заменить .catch(() => {}) на явную обработку +useEffect(() => { + if (!wsId) return; + setLoadingData(true); + setLoadError(null); + Promise.all([ + workspacesApi.listMembers(wsId), + labelsApi.listLabels(wsId), + wfApi.listWorkflows(wsId), + ]) + .then(([m, l, wfs]) => { setMembers(m); setLabels(l); setWorkflows(wfs); }) + .catch((err) => { + const status = (err as { status?: number })?.status; + // 403 — роль определена, прав нет; не показываем retry + // 5xx / network — роль неизвестна, нужен retry + setLoadError(status === 403 ? null : 'Не удалось загрузить данные — попробуйте обновить страницу'); + logger.warn('WorkspaceSettingsPage: load failed', { error: String(err), status }); + }) + .finally(() => setLoadingData(false)); +}, [wsId]); + +// 3. Вычислять isOwner только после загрузки +const myRole = loadingData ? undefined : members.find((m) => m.userId === currentUser?.id)?.role; +const isOwner = myRole === 'OWNER'; + +// 4. Guard в renderWorkflows() — три состояния +if (loadingData) return ; +if (loadError) return ; +// Успешно загружено, но не OWNER — показываем read-only + пояснение +if (!isOwner) return ; + +// WorkflowEditor.tsx — без изменений (isOwner prop корректен) +``` + +```typescript +// WorkflowsReadOnly — список workflow без кнопок + подсказка для не-OWNER +function WorkflowsReadOnly({ workflows }: { workflows: Workflow[] }) { + return ( +
+

+ Редактирование workflow доступно только владельцу воркспейса +

+ {workflows.map(wf => ( +
+ {wf.name} + {wf.isDefault && Default} +
+ ))} +
+ ); +} + +// ErrorRetry — только для 5xx / network +function ErrorRetry({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+

{message}

+ +
+ ); +} +``` + +## Scope +- `frontend/src/pages/WorkspaceSettingsPage.tsx` — заменить `.catch(() => {})`, добавить `loadingData` / `loadError` state, skeleton и ErrorRetry +- Убедиться, что `isOwner` вычисляется только после того, как `loadingData = false` И `currentUser` не null + +## Out of Scope +- Изменение RBAC (MEMBER по-прежнему не может редактировать workflows) +- Новые API-эндпоинты +- WorkflowEditor.tsx — props не меняются + +## Constraints +- `currentUser` берётся из `useAuth()` — нужно проверить, что AuthContext заполнен к моменту render или добавить проверку `!currentUser` в условие загрузки +- Существующие `handleCreateWorkflow` / `handleDeleteWorkflow` уже имеют свой `.catch` — не трогать +- `Promise.all` — если падает ANY запрос, показываем ошибку по всему блоку (не partial) + +## Acceptance Criteria +- [ ] При успешной загрузке OWNER видит активные кнопки "Создать workflow" и "Редактировать" +- [ ] При загрузке показывается skeleton (не disabled-кнопки) +- [ ] При 5xx / сетевой ошибке — сообщение + кнопка "Обновить", не ghost-lock +- [ ] VIEWER / MEMBER видят список workflow + подсказку "Редактирование workflow доступно только владельцу воркспейса" +- [ ] Подсказка НЕ показывается при технической ошибке загрузки (не путать "нет прав" с "не загрузилось") +- [ ] E2E: `admin@flowtask.dev` → `/settings?tab=workflows` → кнопка "Создать workflow" `toBeEnabled()` +- [ ] E2E: `user@flowtask.dev` (MEMBER) → `/settings?tab=workflows` → кнопка "Создать workflow" отсутствует