feat: add launcher frontend MVP
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
# NODE.DC Launcher Design
|
||||||
|
|
||||||
|
Этот документ фиксирует рабочий дизайн-канон именно для `NODE.DC Launcher`. Источники: `doc/base/nodedc_launcher_tz_frontend_mvp.md`, `doc/base/BASE_THINK.md`, `doc/base/HDESIGN-CODE.md` и визуальный референс `doc/base/VISUALREF.png`.
|
||||||
|
|
||||||
|
## 1. Продуктовая рамка
|
||||||
|
|
||||||
|
Launcher - отдельный control plane экосистемы NODE.DC. Он не заменяет NodeDC, Task Manager, Plane, 1C Assistant или будущие сервисы. Его интерфейс показывает доступные приложения, объясняет доступ и даёт администраторам управлять клиентами, участниками, группами, каталогом сервисов, инвайтами и синхронизацией.
|
||||||
|
|
||||||
|
На текущем фронтовом MVP реальный Authentik не подключается. Но интерфейс и данные должны быть собраны так, чтобы позже заменить mock-профиль на OIDC claims без переписывания экранов.
|
||||||
|
|
||||||
|
## 2. Визуальная цель
|
||||||
|
|
||||||
|
Launcher должен ощущаться как витрина приложений, а не как таблица с плитками. Пользовательский экран строится вокруг трёх зон:
|
||||||
|
|
||||||
|
- `Service Stage`: большая визуальная сцена выбранного сервиса.
|
||||||
|
- `Service Detail`: glass-карточка с названием, описанием, статусом и действием.
|
||||||
|
- `Service Rail`: нижняя лента доступных сервисов.
|
||||||
|
|
||||||
|
Референс `VISUALREF.png` даёт направление: тёмная монохромная сцена, слои полупрозрачного glass, мягкий blur, крупная фокусная карточка, плавающая панель управления и нижняя лента элементов. Мы не копируем чужой UI буквально, а переносим композицию, ощущение глубины, верхние толстые pill-кнопки и плавающие окна.
|
||||||
|
|
||||||
|
## 2.1. Канон Task Manager, перенесённый в Launcher
|
||||||
|
|
||||||
|
Launcher должен наследовать уже сделанный NODE.DC Task Manager, а не жить отдельной визуальной веткой.
|
||||||
|
|
||||||
|
Из Task Manager переносим:
|
||||||
|
|
||||||
|
- логотип `NODE.DC` из `demo-assets/logo.svg` / `apps/admin/app/assets/logos/nodedc-logo.svg`;
|
||||||
|
- admin header: лого слева, центральная группа `app-dot + nav-track`, user control справа;
|
||||||
|
- top nav items: крупные круглые pill-кнопки без внешней рамки, высота и паддинги как в Task Manager;
|
||||||
|
- active top item: светлая filled-плашка с тёмным текстом;
|
||||||
|
- user button: прозрачная зона + круглый avatar/control справа;
|
||||||
|
- modal/window shell: `nodedc-glass-modal`-логика, matte black glass, radius `1.75rem`, close `X` в правом верхнем углу;
|
||||||
|
- settings shell: left navigation + content area, карточки `nodedc-settings-card`, поля `nodedc-settings-input`, кнопки `nodedc-settings-save-button` / `nodedc-settings-secondary-button`;
|
||||||
|
- dropdown shell: `nodedc-dropdown-surface`, portal/floating layer, без inline popup внутри карточек.
|
||||||
|
|
||||||
|
Нельзя делать Launcher-шапку, кнопки или админ-окна локально "на глаз", если в Task Manager уже есть паттерн.
|
||||||
|
|
||||||
|
В верхней шапке запрещены технические runtime/debug-панели, даты MVP, mock labels и любые отладочные карточки. Dev-переключатели ролей допустимы только как часть пользовательского nav-track и должны выглядеть как production controls.
|
||||||
|
|
||||||
|
## 3. Роли в дизайне
|
||||||
|
|
||||||
|
Разный доступ должен быть виден в самом UI, даже без реальной авторизации:
|
||||||
|
|
||||||
|
- `root_admin`: видит полный каталог, скрытые/отключённые сервисы, всех клиентов и системную диагностику.
|
||||||
|
- `client_owner` / `client_admin`: видит только свой клиент, его участников, группы, инвайты и доступы к уже подключённым сервисам.
|
||||||
|
- `member`: видит только витрину доступных ему сервисов и профиль.
|
||||||
|
|
||||||
|
В MVP допустим dev-переключатель профиля, но он должен моделировать будущие роли, а не быть случайным режимом интерфейса.
|
||||||
|
|
||||||
|
## 4. Цвета и токены
|
||||||
|
|
||||||
|
Используем CSS variables, совместимые с glass-canon NODE.DC:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--nodedc-accent-rgb: 195 255 102;
|
||||||
|
--nodedc-card-passive-rgb: 42 43 46;
|
||||||
|
--nodedc-card-active-rgb: 195 255 102;
|
||||||
|
--nodedc-on-accent-rgb: 11 17 23;
|
||||||
|
--launcher-radius-modal: 1.75rem;
|
||||||
|
--launcher-radius-card: 1.35rem;
|
||||||
|
--launcher-radius-control: 1.25rem;
|
||||||
|
--launcher-radius-circle: 999px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Primary CTA на светлом акценте всегда получает тёмный текст. Белый текст на светло-зелёной заливке запрещён.
|
||||||
|
|
||||||
|
Фон приложения моноцветный: базовый `#050506`. Нельзя добавлять цветные декоративные фоны, орбы, bokeh, радужные подложки или самостоятельные fullscreen-градиенты. Цвет сервиса может жить внутри media-card/stage preview и в акцентных controls, но не должен перекрашивать весь экран приложения.
|
||||||
|
|
||||||
|
## 5. Surface и glass
|
||||||
|
|
||||||
|
Базовый surface:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.launcher-glass-surface {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||||
|
rgba(8, 8, 11, 0.78);
|
||||||
|
backdrop-filter: blur(28px);
|
||||||
|
-webkit-backdrop-filter: blur(28px);
|
||||||
|
border: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 64px rgba(0, 0, 0, 0.42),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
|
border-radius: var(--launcher-radius-card);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Popup, admin overlay, rail-карточки, формы и таблицы используют этот канон. Светлые прямоугольные окна внутри тёмного экрана не используются.
|
||||||
|
|
||||||
|
## 6. Радиусы
|
||||||
|
|
||||||
|
- Модалки и большой admin overlay: `1.75rem`.
|
||||||
|
- Стандартные карточки и panels: `1.35rem`.
|
||||||
|
- Инпуты, селекты, CTA, chips: `1.25rem`.
|
||||||
|
- Icon actions и status anchors: круг или `999px`.
|
||||||
|
|
||||||
|
## 7. Кнопки и controls
|
||||||
|
|
||||||
|
- Primary: акцентная заливка, тёмный текст, без outline.
|
||||||
|
- Secondary: тёмный glass surface, мягкий hover.
|
||||||
|
- Danger: мягкая danger-заливка без кислотных рамок.
|
||||||
|
- Icon actions: круглые, с tooltip/title, без квадратной active-плашки.
|
||||||
|
- Binary settings: круглый checker, а не стандартный квадратный checkbox.
|
||||||
|
|
||||||
|
Внешние outline запрещены вообще. Не использовать `border` как контур кнопки/карточки/окна. Не использовать синий browser outline. Focus-состояние делается через изменение surface/hover-state, а не через рамку.
|
||||||
|
|
||||||
|
Верхние кнопки:
|
||||||
|
|
||||||
|
- только pill/circle geometry;
|
||||||
|
- `min-height` около `2.75rem`;
|
||||||
|
- нормальный horizontal padding, текст не липнет к радиусу;
|
||||||
|
- active item filled, а не outlined;
|
||||||
|
- рядом стоящие controls должны иметь одну высоту.
|
||||||
|
|
||||||
|
## 8. Dropdown / popup
|
||||||
|
|
||||||
|
Все dropdown, которые открываются внутри карточек, таблиц, scroll-контейнеров, sticky header или detail panel, должны рендериться через portal. Inline popup внутри ограниченного контейнера считается дефектом, потому что создаёт clipping и визуальное налезание.
|
||||||
|
|
||||||
|
Для текущего MVP, где dropdown можно заменить компактным selector/segmented control без clipping-риска, portal можно не вводить насильно. Но reusable `PortalDropdown` остаётся обязательной точкой расширения.
|
||||||
|
|
||||||
|
## 9. Пользовательская витрина
|
||||||
|
|
||||||
|
Top bar:
|
||||||
|
|
||||||
|
- логотип NODE.DC Launcher;
|
||||||
|
- активный клиент;
|
||||||
|
- переключатель mock-профиля;
|
||||||
|
- профиль пользователя;
|
||||||
|
- кнопка администрирования только при праве `canOpenAdmin`.
|
||||||
|
|
||||||
|
Service Stage:
|
||||||
|
|
||||||
|
- всегда держит один большой video/stage-контейнер под будущий фоновой ролик;
|
||||||
|
- при выборе сервиса поверх video/stage появляются два блока: image-card выбранного сервиса и glass-description;
|
||||||
|
- glass-description обязан использовать `backdrop-filter` / `-webkit-backdrop-filter`, чтобы реально блюрить контент за плашкой;
|
||||||
|
- повторный клик по уже выбранной плитке закрывает image-card и glass-description, video/stage остаётся на месте;
|
||||||
|
- имеет fallback на абстрактный service preview;
|
||||||
|
- меняется при выборе сервиса;
|
||||||
|
- не должен выглядеть как marketing hero.
|
||||||
|
|
||||||
|
Service Rail:
|
||||||
|
|
||||||
|
- нижняя единая dock-лента сервисов;
|
||||||
|
- сервисы отображаются квадратными модульными плитками, а не широкими разрозненными карточками;
|
||||||
|
- плитка содержит icon/preview, название и статус;
|
||||||
|
- active state использует `--nodedc-card-active-rgb`;
|
||||||
|
- maintenance видна, но action disabled;
|
||||||
|
- hidden/disabled не видны обычному пользователю.
|
||||||
|
|
||||||
|
## 10. Admin overlay
|
||||||
|
|
||||||
|
Admin overlay - floating glass-window поверх Launcher, а не отдельная публичная страница и не full-screen page.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- фон Launcher остаётся видимым под затемнением/blur;
|
||||||
|
- выбранный сервис, клиент и профиль в Launcher не сбрасываются при открытии/закрытии;
|
||||||
|
- окно закрывается круглой кнопкой `X` в правом верхнем углу;
|
||||||
|
- клик по затемнённому фону может закрыть окно, если нет незавершённой формы;
|
||||||
|
- внутренний layout повторяет settings/admin паттерн Task Manager: left nav + content;
|
||||||
|
- окно имеет max-width/max-height и не занимает весь viewport на desktop.
|
||||||
|
|
||||||
|
Root admin разделы:
|
||||||
|
|
||||||
|
- Обзор;
|
||||||
|
- Клиенты;
|
||||||
|
- Участники;
|
||||||
|
- Группы;
|
||||||
|
- Каталог сервисов;
|
||||||
|
- Доступы;
|
||||||
|
- Инвайты;
|
||||||
|
- Синхронизация;
|
||||||
|
- Аудит.
|
||||||
|
|
||||||
|
Client admin разделы:
|
||||||
|
|
||||||
|
- Обзор;
|
||||||
|
- Участники;
|
||||||
|
- Группы;
|
||||||
|
- Доступы к сервисам;
|
||||||
|
- Инвайты;
|
||||||
|
- Профиль компании.
|
||||||
|
|
||||||
|
Client admin не видит других клиентов и не может редактировать глобальный каталог сервисов.
|
||||||
|
|
||||||
|
## 11. Access UI
|
||||||
|
|
||||||
|
Матрица доступов должна показывать итог, а не только сырые grants:
|
||||||
|
|
||||||
|
- есть ли доступ;
|
||||||
|
- можно ли открыть сервис;
|
||||||
|
- источник доступа: клиент, группа, пользователь, исключение;
|
||||||
|
- роль в приложении;
|
||||||
|
- объяснение причины.
|
||||||
|
|
||||||
|
Deny exception перекрывает любые client/group/user grants. Это должно быть видно в ячейке и explanation panel.
|
||||||
|
|
||||||
|
## 12. Тексты
|
||||||
|
|
||||||
|
Пользовательский UI на русском. В production-видимых элементах не оставлять `Created at`, `Updated at`, `Workspace`, `Project`, `Label`, если экран уже русифицирован.
|
||||||
|
|
||||||
|
## 13. MVP-границы
|
||||||
|
|
||||||
|
Сейчас делаем фронтовую механику:
|
||||||
|
|
||||||
|
- mock API;
|
||||||
|
- mock Authentik claims;
|
||||||
|
- переключение профилей для проверки ролей;
|
||||||
|
- вычисление доступов на фронте;
|
||||||
|
- интерфейс витрины;
|
||||||
|
- admin overlay;
|
||||||
|
- матрицу доступов;
|
||||||
|
- mock invite/sync/audit.
|
||||||
|
|
||||||
|
Не делаем сейчас:
|
||||||
|
|
||||||
|
- реальный Authentik login;
|
||||||
|
- production SSO;
|
||||||
|
- email-инвайты;
|
||||||
|
- backend CRUD;
|
||||||
|
- синхронизацию с Plane/NodeDC;
|
||||||
|
- управление проектами, workflow, задачами или runtime-правами внутри подключённых сервисов.
|
||||||
|
After Width: | Height: | Size: 11 MiB |
|
|
@ -0,0 +1,424 @@
|
||||||
|
# HDESIGN CODE
|
||||||
|
|
||||||
|
Документ фиксирует канон интерфейса NODE.DC, чтобы не обсуждать одни и те же правила повторно.
|
||||||
|
|
||||||
|
Связанные документы:
|
||||||
|
- архитектурный регламент dropdown-окон: [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md)
|
||||||
|
- экранный аудит и backlog миграции: [HUI-CANON-AUDIT.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HUI-CANON-AUDIT.md)
|
||||||
|
- активный техдолг по незавершенной миграции dropdown-layer: [plane-src/docs/technical-debts/dropdown-standardization-debt.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/docs/technical-debts/dropdown-standardization-debt.md)
|
||||||
|
|
||||||
|
## Источник цветов
|
||||||
|
- Основной runtime-конфиг цветов: [design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/design.config.json)
|
||||||
|
- Рабочая web-копия: [plane-src/apps/web/design.config.json](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src/apps/web/design.config.json)
|
||||||
|
- В рантайме используются CSS variables:
|
||||||
|
- `--nodedc-accent-rgb`
|
||||||
|
- `--nodedc-card-passive-rgb`
|
||||||
|
- `--nodedc-card-active-rgb`
|
||||||
|
|
||||||
|
## Цветовые правила
|
||||||
|
- `accent_rgb`: акцентный цвет интерфейса.
|
||||||
|
- `passive_card_rgb`: пассивные карточки.
|
||||||
|
- `active_card_rgb`: активные карточки и primary CTA в зелёной теме.
|
||||||
|
- Primary/active элементы используют акцентный или `active_card_rgb`.
|
||||||
|
- Secondary элементы не должны иметь ярких outline и цветных рамок без явной причины.
|
||||||
|
|
||||||
|
## Радиусы
|
||||||
|
- Главные модалки и большие surface-контейнеры: `1.75rem`
|
||||||
|
- Стандартные glass-карточки и settings-карточки: `1.35rem`
|
||||||
|
- Поля ввода, селекты, secondary/primary кнопки, chip-кнопки: `1.25rem`
|
||||||
|
- Малые круглые action-кнопки: `999px` или полный круг при квадратной коробке
|
||||||
|
|
||||||
|
## Outline и рамки
|
||||||
|
- Внешние outline у контролов запрещены.
|
||||||
|
- Синий browser outline должен быть снят и заменён на нормальный hover/focus surface.
|
||||||
|
- Если нужен контур, он должен быть частью дизайна:
|
||||||
|
- мягкий glass border
|
||||||
|
- акцентный border для drag/drop или active-state
|
||||||
|
- Красные технические outline, debug-рамки и случайные browser-box shadow запрещены.
|
||||||
|
|
||||||
|
## Glass и фон
|
||||||
|
- Popup, dropdown, modal, sidebar overlays и settings-карточки используют matte black glass.
|
||||||
|
- База:
|
||||||
|
- тёмный полупрозрачный фон
|
||||||
|
- `backdrop-filter: blur(...)`
|
||||||
|
- мягкая стеклянная граница
|
||||||
|
- Popup не должны выглядеть просто прозрачными. Если blur не читается, проблема в слое рендера, а не в одном `rgba`.
|
||||||
|
|
||||||
|
## Кнопки
|
||||||
|
- Все кнопки без жёсткого outline.
|
||||||
|
- Текстовые кнопки в модалках не сжимают текст:
|
||||||
|
- минимальный горизонтальный отступ от текста до края кнопки: `10px`
|
||||||
|
- для CTA предпочтительно использовать общий padding `1.25rem` или больше
|
||||||
|
- текст не должен визуально прилипать к радиусу кнопки
|
||||||
|
- Primary button:
|
||||||
|
- фон: акцентный или `active_card_rgb`
|
||||||
|
- текст: определяется автоматически по контрасту заливки
|
||||||
|
- если заливка светлая, текст тёмный
|
||||||
|
- если заливка тёмная, текст светлый
|
||||||
|
- hover: более светлая версия того же цвета
|
||||||
|
- правило распространяется на все filled CTA:
|
||||||
|
- `Добавить`
|
||||||
|
- `Сохранить`
|
||||||
|
- `Обновить`
|
||||||
|
- `Принять`
|
||||||
|
- `Добавить запрос`
|
||||||
|
- любые акцентные toolbar-кнопки
|
||||||
|
- это правило обязательно и для `Внешних контуров`: `Добавить запрос` не может иметь светлый текст на светлом фоне
|
||||||
|
- Save/update button:
|
||||||
|
- если это CTA на `accent_rgb` или `active_card_rgb`, текст не задаётся вручную белым или чёрным
|
||||||
|
- используется системное контрастное значение
|
||||||
|
- hover осветляет текущий тон, а не уходит в синий
|
||||||
|
- Secondary button:
|
||||||
|
- тёмный glass фон
|
||||||
|
- без border-outline
|
||||||
|
- hover немного светлее базового surface
|
||||||
|
- Danger button:
|
||||||
|
- без кислотно-красных рамок
|
||||||
|
- мягкий danger surface
|
||||||
|
|
||||||
|
## Поля и селекты
|
||||||
|
- Все поля ввода, textarea, select, chip-select:
|
||||||
|
- скруглённые
|
||||||
|
- без внешних outline
|
||||||
|
- glass background
|
||||||
|
- единая вертикальная высота для одного класса контролов
|
||||||
|
- Placeholder и label должны быть читаемы и не прилипать к краям.
|
||||||
|
|
||||||
|
## Чекеры
|
||||||
|
- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения.
|
||||||
|
- Активное состояние:
|
||||||
|
- круг залит `rgb(var(--nodedc-accent-rgb))`
|
||||||
|
- внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))`
|
||||||
|
- Неактивное состояние:
|
||||||
|
- круг на мягком `white/10`
|
||||||
|
- без внешнего outline и без синей browser-рамки
|
||||||
|
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
|
||||||
|
- В деталях задачи структурные блоки создаются из меню `Добавить подэлемент` прямо в карточке, без отдельной модалки:
|
||||||
|
- порядок меню: `Создать текстовый блок`, `Создать чекер`, `Создать новую подзадачу`, `Добавить существующую подзадачу`
|
||||||
|
- текстовый блок содержит два поля: необязательный заголовок и текст
|
||||||
|
- чекер отображается без внешней подложки и без заголовка: только строки с круглым checker-якорем и plus-зона добавления строки
|
||||||
|
- первые 10 строк чекера видны сразу, дальше включается внутренний скролл списка
|
||||||
|
- у каждого структурного блока справа есть меню `...` с удалением блока
|
||||||
|
- блок хранится в штатном JSON-поле задачи `detail_layout`, а не в `description_html`: описание проходит HTML-sanitizer и не должно нести layout-состояние
|
||||||
|
- `detail_layout` является частью самой задачи, поэтому кастомные поля мультиплеерны и восстанавливаются после закрытия/повторного открытия карточки
|
||||||
|
|
||||||
|
## Toolbar и верхние панели
|
||||||
|
- Элементы верхней панели центрируются по одной горизонтальной оси.
|
||||||
|
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
|
||||||
|
- Кнопки `Отображение`, `Аналитика`, `Добавить рабочий элемент`:
|
||||||
|
- одинаковая высота
|
||||||
|
- каноничные радиусы
|
||||||
|
- нормальные горизонтальные paddings, чтобы текст не лип к краям
|
||||||
|
|
||||||
|
## Карточки
|
||||||
|
- Внутренние карточки строятся по симметричным верхним и нижним padding.
|
||||||
|
- Верхняя ось:
|
||||||
|
- аватар
|
||||||
|
- имя
|
||||||
|
- вторичная строка
|
||||||
|
- action-circle справа
|
||||||
|
должны сидеть на согласованной геометрии
|
||||||
|
- Нижняя ось:
|
||||||
|
- assignee bubbles
|
||||||
|
- дата
|
||||||
|
должны быть симметричны верхней
|
||||||
|
- Для списков карточек `Внешних контуров` используется тот же вертикальный ритм, что и у `Внутреннего контура`:
|
||||||
|
- контейнер списка не плотнее `space-y-3`
|
||||||
|
- нельзя лепить карточки вплотную друг к другу
|
||||||
|
|
||||||
|
## Dropdown и popup
|
||||||
|
- Все dropdown/popup приводятся к единому matte glass канону.
|
||||||
|
- Подробный архитектурный и поведенческий регламент dropdown-окон вынесен в [HDROPDOWN-CANON.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/HDROPDOWN-CANON.md).
|
||||||
|
- Запрещены:
|
||||||
|
- квадратные active-box вокруг круглых кнопок
|
||||||
|
- жёсткие border-outline
|
||||||
|
- светлый фон, если основной экран тёмный
|
||||||
|
- Search shell внутри popup должен использовать тот же стиль, что и сам popup.
|
||||||
|
- Filled CTA внутри popup и модалок подчиняются тому же правилу:
|
||||||
|
- светлый акцентный фон
|
||||||
|
- контрастный текст от реальной яркости фона
|
||||||
|
- hover только в более светлый тон того же цвета
|
||||||
|
|
||||||
|
### Portal правило
|
||||||
|
- Если selector/dropdown открывается внутри:
|
||||||
|
- scroll-контейнера
|
||||||
|
- detail-pane
|
||||||
|
- карточки
|
||||||
|
- properties section
|
||||||
|
- sidebar
|
||||||
|
- sticky header
|
||||||
|
он не должен рендериться inline.
|
||||||
|
- Такой popup обязан рендериться на верхнем слое через `portal` (`document.body` или эквивалент).
|
||||||
|
- Inline popup в ограниченном контейнере считается дефектом, потому что даёт:
|
||||||
|
- клиппинг
|
||||||
|
- налезание на соседние блоки
|
||||||
|
- старую “врезанную” верстку
|
||||||
|
- Для `Свойств` в `Внешних контурах` dropdown по умолчанию открывается вверх:
|
||||||
|
- `placement="top-start"`
|
||||||
|
- причина: блок находится близко к `Активности`, popup не должен падать вниз в соседнюю секцию
|
||||||
|
|
||||||
|
### Portal anchor snippet
|
||||||
|
```tsx
|
||||||
|
{isOpen &&
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
createPortal(
|
||||||
|
<Combobox.Options className="fixed z-[420]" static>
|
||||||
|
<div
|
||||||
|
data-prevent-outside-click
|
||||||
|
className="nodedc-dropdown-surface nodedc-external-popup-anchor"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</Combobox.Options>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reusable классы
|
||||||
|
- Accent CTA:
|
||||||
|
- `.nodedc-external-primary-button`
|
||||||
|
- текст внутри всегда `#0b1117`
|
||||||
|
- Secondary action:
|
||||||
|
- `.nodedc-external-action-button`
|
||||||
|
- Secondary icon action:
|
||||||
|
- `.nodedc-external-icon-button`
|
||||||
|
- Readonly property/control surface:
|
||||||
|
- `.nodedc-external-readonly-value`
|
||||||
|
- `.nodedc-modal-field`
|
||||||
|
- External property rows:
|
||||||
|
- `.nodedc-external-property-row`
|
||||||
|
- `.nodedc-external-property-label`
|
||||||
|
- `.nodedc-external-property-value`
|
||||||
|
- `.nodedc-external-property-control`
|
||||||
|
- Dropdown shell:
|
||||||
|
- `.nodedc-dropdown-surface`
|
||||||
|
- `.nodedc-dropdown-search`
|
||||||
|
- `.nodedc-dropdown-option`
|
||||||
|
- External contour card/shell:
|
||||||
|
- `.nodedc-external-card`
|
||||||
|
- `.nodedc-external-section`
|
||||||
|
- `.nodedc-external-content-shell`
|
||||||
|
- Intake filter chips:
|
||||||
|
- `.nodedc-filter-chip`
|
||||||
|
|
||||||
|
### Anchor snippets
|
||||||
|
```tsx
|
||||||
|
<Button className="nodedc-external-primary-button">...</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconButton className="nodedc-external-icon-button" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="nodedc-external-readonly-value">
|
||||||
|
<SomeIcon className="h-3.5 w-3.5 text-tertiary" />
|
||||||
|
<span className="text-12 font-medium text-primary">...</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="nodedc-external-property-row">
|
||||||
|
<div className="nodedc-external-property-label">
|
||||||
|
<SomeIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>Label</span>
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-external-property-value">
|
||||||
|
<SomeValue />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Dropdown
|
||||||
|
button={
|
||||||
|
<div className="nodedc-external-property-control text-[13px] font-medium">
|
||||||
|
<SomeIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
||||||
|
<span className="text-primary">Value</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drag and drop
|
||||||
|
- Drag overlay использует акцентный контур.
|
||||||
|
- Во внутреннем kanban после успешного ручного переноса карточка остается в активной заливке `active_card_rgb` до выбора другой карточки/следующего переноса; сам drag-жест не открывает detail pane.
|
||||||
|
- Delete dropzone:
|
||||||
|
- без красного технического свечения и без red-tinted text/fill
|
||||||
|
- текст локализован
|
||||||
|
- акцентный outline обязателен
|
||||||
|
|
||||||
|
## Тексты
|
||||||
|
- Пользовательский UI на русском, если экран русифицирован.
|
||||||
|
- Не оставлять смешанные подписи вида `Created at / Updated at / Label / State group`, если экран уже на русском.
|
||||||
|
|
||||||
|
## Правило внедрения
|
||||||
|
- Новый экран или popup не стилизуется локально “на глаз”.
|
||||||
|
- Сначала используется существующий shared-класс или shared-component.
|
||||||
|
- Если shared-слоя нет, создаётся reusable-класс/компонент и уже через него приводятся все похожие места.
|
||||||
|
- Цель: не точечная покраска одного окна, а единый системный канон.
|
||||||
|
- Если блок визуально расходится со стилем системы, не добавлять поверх временную wrapper-заплатку. Нужно либо перевести блок на shared-компонент, либо переверстать локальную структуру под shared-классы.
|
||||||
|
- Для экранов со вкладками/переключателями нельзя оставлять flash старой верстки. Перед refetch нужно очищать stale store-data и показывать loading shell.
|
||||||
|
- Если карточки или списки разных модулей должны быть одинаковыми по канону, нельзя лечить это внешней обёрткой. Нужно менять сам внутренний layout item-компонента.
|
||||||
|
- Для `Внешних контуров` это значит:
|
||||||
|
- список карточек правится на уровне `list-item.tsx`, а не через внешний wrapper
|
||||||
|
- gap между карточками должен совпадать с каноном `Внутреннего контура`
|
||||||
|
- актуальный gap списка на текущем каноне: `space-y-3`
|
||||||
|
- при tab switch между `Открытые / Закрытые` нельзя полагаться только на route param; нужен локальный `pendingTab`, чтобы stale layout не мелькал до завершения refetch
|
||||||
|
- toolbar-навигация и inline actions не должны использовать старые квадратные `IconButton` остатки
|
||||||
|
- свойства `Приоритет / Метки / Статус` не должны рисовать внутренние boxed-chip артефакты
|
||||||
|
- popup `Приоритет / Метки` не может визуально жить внутри property-row; если он открывается из blur-shell, он обязан уходить в portal и рендериться над секцией
|
||||||
|
- filled CTA вроде `Добавить запрос` используют `nodedc-external-primary-button` и всегда имеют тёмный текст
|
||||||
|
- filled CTA используют чёрный/почти-чёрный текст всегда; белый текст на светлом акценте запрещён
|
||||||
|
- secondary meta-иконки в карточке списка не должны иметь отдельную серую подложку, если по канону это простой inline icon
|
||||||
|
- empty-state не должен использовать декоративную серую подложку под SVG; media-box прозрачный, SVG выравнивается через `display:flex` и центрирование
|
||||||
|
- detail-toolbar в карточке запроса использует общий glass-cluster для листания `prev/next`, а сами кнопки внутри кластера — круглые, без квадратной подложки
|
||||||
|
- `Добавить запрос` в header `Внешних контуров` — это filled accent CTA с тёмным текстом, каноничным радиусом и hover в более светлый тон того же акцента
|
||||||
|
- global sidebar quick action `Новый рабочий элемент` не показывается на маршруте `external-contours`, потому что этот экран уже имеет собственный primary CTA в header
|
||||||
|
- active/passive карточки `Внешних контуров` обязаны брать фон только из `--nodedc-card-active-rgb` и `--nodedc-card-passive-rgb`
|
||||||
|
- header `Внешних контуров` и detail-pane опускаются на единый верхний ритм; нельзя прижимать breadcrumbs, CTA и detail-header к верхней кромке
|
||||||
|
- Для `Предложений / Intake` это значит:
|
||||||
|
- правая detail-pane не растягивается на всю свободную ширину экрана; она использует тот же `IssueView` side-peek shell и тот же persisted width, что и `Внутренний контур`
|
||||||
|
- top-toolbar `Предложений` не верстается отдельной локальной шапкой; используется тот же peek header, что и у `Внутреннего контура`, а intake-специфичные actions добавляются только как slot
|
||||||
|
- `Открытые / Закрытые` не живут отдельными tab-кнопками внутри левой колонки; для intake статус — это обычный filter, а не отдельный режим layout
|
||||||
|
- кнопки `Фильтры / Сортировка` не остаются внутри списка intake; они выносятся в верхний header cluster по тому же паттерну, что и у `Внутреннего контура`
|
||||||
|
- dropdown фильтров и сортировки не могут жить под карточками списка; popup обязан иметь верхний z-layer и не конфликтовать со scroll/list слоями
|
||||||
|
- search shell внутри intake filter dropdown использует тот же matte glass, что и остальные dropdown/popup
|
||||||
|
- applied filter chips в intake не используют старые `Tag`-плашки `Plane`; они приводятся к glass-chip канону через `.nodedc-filter-chip`
|
||||||
|
- intake-list использует тот же shared `nodedc-work-item-card` shell, что и карточка `Внутреннего контура`; intake допускает только контекстные отличия в meta/footer, а не отдельную геометрию карточки
|
||||||
|
- правая detail-pane `Предложений` не изобретает собственные section-shell; title, description, properties и activity используют тот же peek/details rhythm, что и `Внутреннем контуре`
|
||||||
|
- режим `full-screen` у detail-pane переводит свойства в правую колонку по тому же принципу, что и в `Внутреннем контуре`
|
||||||
|
- activity/comment composer внутри узкой detail-pane должен использовать compact peek-канон, а не растянутый page-form вид
|
||||||
|
- header intake-detail не использует внешнеконтурный toolbar как есть; sequence pill, status pill и decision buttons собираются в один compact peek-row без вылета за край detail-pane
|
||||||
|
- CTA `Принять / Отклонить` в intake-detail не могут иметь фиксированную ширину, которая ломает side-peek; на светлом accent-fill текст всегда тёмный, hover идёт в более светлый тон того же акцента
|
||||||
|
- модалка `Создать входящий рабочий элемент` центрируется как остальные create/edit modal, использует glass shell, `nodedc-modal-input`, `nodedc-modal-editor`, `nodedc-modal-primary-button` и `nodedc-modal-secondary-button`
|
||||||
|
- quick-actions menu по троеточию на карточке обязано открываться из корректного viewport-anchor без оффсета; если локальный card-layer ломает геометрию, menu возвращается в `body` portal, но сохраняет правильный z-layer и привязку к trigger
|
||||||
|
- quick-actions по троеточию не реализуются как отдельный спец-вид меню; они используют тот же popper/portal dropdown-паттерн, что и рабочие меню `Статус / Приоритет`, чтобы trigger, offset и z-layer вели себя одинаково
|
||||||
|
- реализация quick-actions выносится в shared `ActionDropdown`; карточки и detail-view не держат собственный `isMenuActive`, локальный outside-click и отдельный anchor-state для `...`
|
||||||
|
- popup выбора `Приоритет / Метки` внутри detail view не рендерится inline в property-row; он обязан уходить в `portal`
|
||||||
|
- секции с dropdown-trigger внутри blur/glass shell обязаны иметь `overflow: visible` и `isolation: isolate`, иначе popup визуально “тонет” внутри блока
|
||||||
|
- при переключении `Открытые / Закрытые` store обязан очистить stale request list до нового fetch, чтобы пользователь не видел flash старой верстки
|
||||||
|
- карточка списка `Внешних контуров` правится на уровне `list-item.tsx`, а не внешней обёрткой:
|
||||||
|
- верхняя и нижняя оси собираются как у карточки `Внутреннего контура`
|
||||||
|
- gap между карточками совпадает с каноном `Внутреннего контура`
|
||||||
|
- empty-state иконки без декоративной подложки; если иконка визуально “плывёт”, корректируется сам SVG/media-box
|
||||||
|
|
||||||
|
### Внешние контуры: code anchors
|
||||||
|
- Header CTA:
|
||||||
|
```tsx
|
||||||
|
<Button className="nodedc-external-primary-button">...</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Route-aware quick action hide:
|
||||||
|
```tsx
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname?.includes("/external-contours")) return null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- List spacing:
|
||||||
|
```tsx
|
||||||
|
<div key={resolvedTab} className="space-y-3">
|
||||||
|
{filteredRequestIds.map((requestId) => (
|
||||||
|
<ExternalContoursListItem key={requestId} ... />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pending tab anti-flash:
|
||||||
|
```tsx
|
||||||
|
const [pendingTab, setPendingTab] = useState<TInboxIssueCurrentTab | null>(null);
|
||||||
|
const routeTab = (searchParams.get("currentTab") as TInboxIssueCurrentTab | null) ?? currentTab;
|
||||||
|
const resolvedTab = pendingTab ?? routeTab;
|
||||||
|
const isTabTransitioning = loader === "init-loading" || pendingTab !== null || routeTab !== currentTab;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Card theme source:
|
||||||
|
```css
|
||||||
|
.nodedc-external-card {
|
||||||
|
background: rgb(var(--nodedc-card-passive-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-external-card[data-active="true"] {
|
||||||
|
background: rgb(var(--nodedc-card-active-rgb));
|
||||||
|
color: #0b1117;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Property popup anchor:
|
||||||
|
```tsx
|
||||||
|
<PriorityDropdown
|
||||||
|
placement="top-start"
|
||||||
|
buttonContainerClassName="nodedc-external-property-control-shell ..."
|
||||||
|
button={
|
||||||
|
<div className="nodedc-external-property-control text-[13px] font-medium">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Detail toolbar cluster:
|
||||||
|
```tsx
|
||||||
|
<div className="nodedc-external-toolbar-cluster">
|
||||||
|
<button type="button" className="nodedc-external-icon-button">...</button>
|
||||||
|
<button type="button" className="nodedc-external-icon-button">...</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Property control:
|
||||||
|
```tsx
|
||||||
|
<div className="nodedc-external-property-control text-[13px] font-medium">
|
||||||
|
<SomeIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
||||||
|
<span className="text-primary">...</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Root tab switch without stale flash:
|
||||||
|
```tsx
|
||||||
|
const [pendingTab, setPendingTab] = useState<TInboxIssueCurrentTab | null>(null);
|
||||||
|
const resolvedTab = pendingTab ?? routeTab;
|
||||||
|
const isTabTransitioning = loader === "init-loading" || pendingTab !== null || routeTab !== currentTab;
|
||||||
|
|
||||||
|
if (resolvedTab !== nextTab) {
|
||||||
|
setPendingTab(nextTab);
|
||||||
|
void handleCurrentTab(workspaceSlug, projectId, nextTab);
|
||||||
|
router.push(`...currentTab=${nextTab}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Store-side tab reset:
|
||||||
|
```ts
|
||||||
|
this.requestIds = [];
|
||||||
|
this.requests = {};
|
||||||
|
this.loader = "init-loading";
|
||||||
|
this.currentTab = tab;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Portal popup с фиксированной стратегией:
|
||||||
|
```tsx
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
strategy: "fixed",
|
||||||
|
placement: placement ?? "bottom-start",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Property popup without boxed artifact:
|
||||||
|
```tsx
|
||||||
|
<IssueLabelSelect
|
||||||
|
rootClassName="w-full overflow-visible"
|
||||||
|
buttonContainerClassName="nodedc-external-property-control-shell h-full w-full overflow-visible"
|
||||||
|
label={
|
||||||
|
<div className="nodedc-external-property-control text-[13px] font-medium">
|
||||||
|
<LabelPropertyIcon className="h-3.5 w-3.5 flex-shrink-0 text-tertiary" />
|
||||||
|
<span className="truncate text-primary">...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Контейнер секции с trigger:
|
||||||
|
```tsx
|
||||||
|
<div className="nodedc-external-section overflow-visible px-4 py-4">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NODE.DC Launcher</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "nodedc-launcher",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.12",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 MiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55">
|
||||||
|
<path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/>
|
||||||
|
<polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 11 MiB |
|
|
@ -0,0 +1,567 @@
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"id": "client_romashka",
|
||||||
|
"type": "company",
|
||||||
|
"name": "ООО Ромашка",
|
||||||
|
"legalName": "ООО Ромашка",
|
||||||
|
"status": "active",
|
||||||
|
"demoEndsAt": null,
|
||||||
|
"contactName": "Иван Петров",
|
||||||
|
"contactEmail": "ivan@romashka.ru",
|
||||||
|
"notes": "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "client_roga_kopyta",
|
||||||
|
"type": "company",
|
||||||
|
"name": "ООО Рога и Копыта",
|
||||||
|
"legalName": "ООО Рога и Копыта",
|
||||||
|
"status": "demo",
|
||||||
|
"demoEndsAt": "2026-06-01T00:00:00Z",
|
||||||
|
"contactName": "Мария Иванова",
|
||||||
|
"contactEmail": "maria@example.ru",
|
||||||
|
"notes": "Клиент на демо-доступе, подключены только базовые сервисы.",
|
||||||
|
"createdAt": "2026-04-10T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "client_private_architect",
|
||||||
|
"type": "person",
|
||||||
|
"name": "Илья Архитектор",
|
||||||
|
"legalName": null,
|
||||||
|
"status": "suspended",
|
||||||
|
"demoEndsAt": "2026-04-20T00:00:00Z",
|
||||||
|
"contactName": "Илья Архитектор",
|
||||||
|
"contactEmail": "ilya@example.ru",
|
||||||
|
"notes": "Пример приостановленного частного клиента.",
|
||||||
|
"createdAt": "2026-03-14T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "user_root",
|
||||||
|
"authentikUserId": "ak-root",
|
||||||
|
"name": "Root Admin",
|
||||||
|
"email": "root@nodedc.local",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_ivan",
|
||||||
|
"authentikUserId": "ak-ivan",
|
||||||
|
"name": "Иван Петров",
|
||||||
|
"email": "ivan@romashka.ru",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_vera",
|
||||||
|
"authentikUserId": "ak-vera",
|
||||||
|
"name": "Вера Соколова",
|
||||||
|
"email": "vera@romashka.ru",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-02T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_vasya",
|
||||||
|
"authentikUserId": "ak-vasya",
|
||||||
|
"name": "Василий Орлов",
|
||||||
|
"email": "vasya@romashka.ru",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-05T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_lena",
|
||||||
|
"authentikUserId": "ak-lena",
|
||||||
|
"name": "Лена Волкова",
|
||||||
|
"email": "lena@romashka.ru",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-08T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_maria",
|
||||||
|
"authentikUserId": "ak-maria",
|
||||||
|
"name": "Мария Иванова",
|
||||||
|
"email": "maria@example.ru",
|
||||||
|
"globalStatus": "active",
|
||||||
|
"createdAt": "2026-04-10T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "user_blocked",
|
||||||
|
"authentikUserId": "ak-blocked",
|
||||||
|
"name": "Олег Заблокирован",
|
||||||
|
"email": "oleg@romashka.ru",
|
||||||
|
"globalStatus": "blocked",
|
||||||
|
"createdAt": "2026-04-12T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"memberships": [
|
||||||
|
{
|
||||||
|
"id": "mem_ivan_romashka",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_ivan",
|
||||||
|
"role": "client_owner",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_vera_romashka",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_vera",
|
||||||
|
"role": "client_admin",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_vasya_romashka",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_vasya",
|
||||||
|
"role": "member",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_lena_romashka",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_lena",
|
||||||
|
"role": "member",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_blocked_romashka",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"userId": "user_blocked",
|
||||||
|
"role": "member",
|
||||||
|
"status": "disabled",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_maria_roga",
|
||||||
|
"clientId": "client_roga_kopyta",
|
||||||
|
"userId": "user_maria",
|
||||||
|
"role": "client_owner",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mem_ivan_roga",
|
||||||
|
"clientId": "client_roga_kopyta",
|
||||||
|
"userId": "user_ivan",
|
||||||
|
"role": "client_admin",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "group_romashka_leads",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"name": "Руководство",
|
||||||
|
"description": "Собственники и руководители клиента.",
|
||||||
|
"memberIds": [
|
||||||
|
"user_ivan",
|
||||||
|
"user_vera"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "group_romashka_accounting",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"name": "Бухгалтерия",
|
||||||
|
"description": "1C и финансовые сценарии.",
|
||||||
|
"memberIds": [
|
||||||
|
"user_lena"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "group_romashka_ops",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"name": "Операторы",
|
||||||
|
"description": "Ежедневная работа в задачах и тендерах.",
|
||||||
|
"memberIds": [
|
||||||
|
"user_vasya",
|
||||||
|
"user_lena"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "group_roga_demo",
|
||||||
|
"clientId": "client_roga_kopyta",
|
||||||
|
"name": "Демо-команда",
|
||||||
|
"description": "Пилотный контур клиента.",
|
||||||
|
"memberIds": [
|
||||||
|
"user_maria",
|
||||||
|
"user_ivan"
|
||||||
|
],
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "service_nodedc",
|
||||||
|
"slug": "nodedc",
|
||||||
|
"title": "NodeDC",
|
||||||
|
"subtitle": "Агентная платформа",
|
||||||
|
"description": "Сборка, запуск и мониторинг агентных workflow.",
|
||||||
|
"fullDescription": "NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
|
||||||
|
"url": "https://dev.handhdc.ru",
|
||||||
|
"launchUrl": "https://dev.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#B5FF5A",
|
||||||
|
"fallbackGradient": "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
|
||||||
|
"status": "active",
|
||||||
|
"order": 10,
|
||||||
|
"authentikApplicationSlug": "nodedc",
|
||||||
|
"authentikGroupName": "service-nodedc",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T15:29:56.933Z",
|
||||||
|
"coverImageUrl": "/storage/uploads/1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
|
||||||
|
"coverMediaKind": "image",
|
||||||
|
"coverMediaSource": "file",
|
||||||
|
"coverMediaFileName": "1777649382309-49d8c393-2026-05-01-17.31.21.jpg",
|
||||||
|
"ambientVideoUrl": "/storage/uploads/1777649395844-a878de95-090aff247929335.69e6521716ea9.gif",
|
||||||
|
"ambientMediaKind": "gif",
|
||||||
|
"ambientMediaSource": "file",
|
||||||
|
"ambientMediaFileName": "1777649395844-a878de95-090aff247929335.69e6521716ea9.gif"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_task_manager",
|
||||||
|
"slug": "task-manager",
|
||||||
|
"title": "Task Manager",
|
||||||
|
"subtitle": "Операционный слой",
|
||||||
|
"description": "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
|
||||||
|
"fullDescription": "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
|
||||||
|
"url": "https://tasks.handhdc.ru",
|
||||||
|
"launchUrl": "https://tasks.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#D7C8FF",
|
||||||
|
"fallbackGradient": "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
|
||||||
|
"status": "active",
|
||||||
|
"order": 20,
|
||||||
|
"authentikApplicationSlug": "task-manager",
|
||||||
|
"authentikGroupName": "service-task-manager",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_1c",
|
||||||
|
"slug": "1c-assistant",
|
||||||
|
"title": "1C Assistant",
|
||||||
|
"subtitle": "Бухгалтерский ассистент",
|
||||||
|
"description": "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
|
||||||
|
"fullDescription": "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
|
||||||
|
"url": "https://1c.handhdc.ru",
|
||||||
|
"launchUrl": "https://1c.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#8FD7FF",
|
||||||
|
"fallbackGradient": "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
|
||||||
|
"status": "maintenance",
|
||||||
|
"order": 30,
|
||||||
|
"authentikApplicationSlug": "1c-assistant",
|
||||||
|
"authentikGroupName": "service-1c-assistant",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_tender",
|
||||||
|
"slug": "tender-agent",
|
||||||
|
"title": "Tender Agent",
|
||||||
|
"subtitle": "Госзакупки и тендеры",
|
||||||
|
"description": "Поиск, анализ и подготовка тендерных решений.",
|
||||||
|
"fullDescription": "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
|
||||||
|
"url": "https://tender.handhdc.ru",
|
||||||
|
"launchUrl": "https://tender.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#FFD166",
|
||||||
|
"fallbackGradient": "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
|
||||||
|
"status": "active",
|
||||||
|
"order": 40,
|
||||||
|
"authentikApplicationSlug": "tender-agent",
|
||||||
|
"authentikGroupName": "service-tender-agent",
|
||||||
|
"createdAt": "2026-04-03T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T15:37:01.591Z",
|
||||||
|
"coverImageUrl": "/storage/uploads/1777649809136-7935fb4d-2026-05-01-18.36.20.jpg",
|
||||||
|
"coverMediaKind": "image",
|
||||||
|
"coverMediaSource": "file",
|
||||||
|
"coverMediaFileName": "1777649809136-7935fb4d-2026-05-01-18.36.20.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_digital_twin",
|
||||||
|
"slug": "digital-twin",
|
||||||
|
"title": "Digital Twin",
|
||||||
|
"subtitle": "3D и пространственные данные",
|
||||||
|
"description": "Просмотр цифровых двойников, карт и объектных сцен.",
|
||||||
|
"fullDescription": "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
|
||||||
|
"url": "https://twin.handhdc.ru",
|
||||||
|
"launchUrl": "https://twin.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#76E4F7",
|
||||||
|
"fallbackGradient": "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
|
||||||
|
"status": "active",
|
||||||
|
"order": 50,
|
||||||
|
"authentikApplicationSlug": "digital-twin",
|
||||||
|
"authentikGroupName": "service-digital-twin",
|
||||||
|
"createdAt": "2026-04-05T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_dm",
|
||||||
|
"slug": "digital-modules",
|
||||||
|
"title": "Digital Modules",
|
||||||
|
"subtitle": "Будущие модули",
|
||||||
|
"description": "Скрытый каталог модулей для root-admin preview.",
|
||||||
|
"fullDescription": "Площадка для будущих цифровых модулей NODE.DC.",
|
||||||
|
"url": "https://dm.handhdc.ru",
|
||||||
|
"launchUrl": "https://dm.handhdc.ru/sso/launch",
|
||||||
|
"accentColor": "#FF9AC2",
|
||||||
|
"fallbackGradient": "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
|
||||||
|
"status": "hidden",
|
||||||
|
"order": 60,
|
||||||
|
"authentikApplicationSlug": "digital-modules",
|
||||||
|
"authentikGroupName": "service-digital-modules",
|
||||||
|
"createdAt": "2026-04-10T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "service_internal",
|
||||||
|
"slug": "internal-tools",
|
||||||
|
"title": "Internal Tools",
|
||||||
|
"subtitle": "Внутренний контур",
|
||||||
|
"description": "Отключённый сервис для проверки диагностики root-admin.",
|
||||||
|
"fullDescription": "Не показывается обычным пользователям, виден root-admin в каталоге.",
|
||||||
|
"url": "https://internal.handhdc.ru",
|
||||||
|
"launchUrl": null,
|
||||||
|
"accentColor": "#F97373",
|
||||||
|
"fallbackGradient": "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
|
||||||
|
"status": "disabled",
|
||||||
|
"order": 70,
|
||||||
|
"authentikApplicationSlug": "internal-tools",
|
||||||
|
"authentikGroupName": "service-internal-tools",
|
||||||
|
"createdAt": "2026-04-12T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{
|
||||||
|
"id": "grant_romashka_task",
|
||||||
|
"serviceId": "service_task_manager",
|
||||||
|
"targetType": "client",
|
||||||
|
"targetId": "client_romashka",
|
||||||
|
"appRole": "member",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_romashka_nodedc_leads",
|
||||||
|
"serviceId": "service_nodedc",
|
||||||
|
"targetType": "group",
|
||||||
|
"targetId": "group_romashka_leads",
|
||||||
|
"appRole": "admin",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_romashka_1c_accounting",
|
||||||
|
"serviceId": "service_1c",
|
||||||
|
"targetType": "group",
|
||||||
|
"targetId": "group_romashka_accounting",
|
||||||
|
"appRole": "member",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_romashka_tender_ops",
|
||||||
|
"serviceId": "service_tender",
|
||||||
|
"targetType": "group",
|
||||||
|
"targetId": "group_romashka_ops",
|
||||||
|
"appRole": "viewer",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_romashka_twin_vasya",
|
||||||
|
"serviceId": "service_digital_twin",
|
||||||
|
"targetType": "user",
|
||||||
|
"targetId": "user_vasya",
|
||||||
|
"appRole": "viewer",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_roga_task",
|
||||||
|
"serviceId": "service_task_manager",
|
||||||
|
"targetType": "client",
|
||||||
|
"targetId": "client_roga_kopyta",
|
||||||
|
"appRole": "member",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grant_roga_nodedc",
|
||||||
|
"serviceId": "service_nodedc",
|
||||||
|
"targetType": "client",
|
||||||
|
"targetId": "client_roga_kopyta",
|
||||||
|
"appRole": "viewer",
|
||||||
|
"status": "active",
|
||||||
|
"createdAt": "2026-04-01T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exceptions": [
|
||||||
|
{
|
||||||
|
"id": "exception_lena_task_deny",
|
||||||
|
"serviceId": "service_task_manager",
|
||||||
|
"userId": "user_lena",
|
||||||
|
"type": "deny",
|
||||||
|
"reason": "Индивидуально отключён Task Manager на период ревизии доступа.",
|
||||||
|
"createdAt": "2026-04-28T10:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"invites": [
|
||||||
|
{
|
||||||
|
"id": "invite_romashka_analyst",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"email": "analyst@romashka.ru",
|
||||||
|
"role": "member",
|
||||||
|
"invitedByUserId": "user_ivan",
|
||||||
|
"token": "romashka-analyst-demo",
|
||||||
|
"expiresAt": "2026-05-15T12:00:00Z",
|
||||||
|
"status": "sent",
|
||||||
|
"createdAt": "2026-04-30T12:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "invite_roga_admin",
|
||||||
|
"clientId": "client_roga_kopyta",
|
||||||
|
"email": "ops@example.ru",
|
||||||
|
"role": "client_admin",
|
||||||
|
"invitedByUserId": "user_maria",
|
||||||
|
"token": "roga-admin-demo",
|
||||||
|
"expiresAt": "2026-05-18T12:00:00Z",
|
||||||
|
"status": "created",
|
||||||
|
"createdAt": "2026-04-30T14:00:00Z",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"syncStatuses": [
|
||||||
|
{
|
||||||
|
"id": "sync_romashka_auth",
|
||||||
|
"objectId": "client_romashka",
|
||||||
|
"objectName": "ООО Ромашка",
|
||||||
|
"objectType": "client",
|
||||||
|
"target": "authentik",
|
||||||
|
"state": "synced",
|
||||||
|
"lastSyncAt": "2026-05-01T08:00:00Z",
|
||||||
|
"error": null,
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sync_task_auth",
|
||||||
|
"objectId": "service_task_manager",
|
||||||
|
"objectName": "Task Manager",
|
||||||
|
"objectType": "service",
|
||||||
|
"target": "authentik",
|
||||||
|
"state": "synced",
|
||||||
|
"lastSyncAt": "2026-05-01T08:00:00Z",
|
||||||
|
"error": null,
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sync_lena_task",
|
||||||
|
"objectId": "exception_lena_task_deny",
|
||||||
|
"objectName": "Deny: Лена / Task Manager",
|
||||||
|
"objectType": "grant",
|
||||||
|
"target": "task_manager",
|
||||||
|
"state": "pending",
|
||||||
|
"lastSyncAt": null,
|
||||||
|
"error": null,
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sync_roga_nodedc",
|
||||||
|
"objectId": "client_roga_kopyta",
|
||||||
|
"objectName": "ООО Рога и Копыта",
|
||||||
|
"objectType": "client",
|
||||||
|
"target": "nodedc",
|
||||||
|
"state": "error",
|
||||||
|
"lastSyncAt": "2026-05-01T08:00:00Z",
|
||||||
|
"error": "OIDC binding ещё не создан для demo-клиента.",
|
||||||
|
"updatedAt": "2026-05-01T09:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auditEvents": [
|
||||||
|
{
|
||||||
|
"id": "audit_1",
|
||||||
|
"at": "2026-05-01T08:40:00Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "Root Admin",
|
||||||
|
"action": "Создан сервис",
|
||||||
|
"objectType": "service",
|
||||||
|
"objectName": "Digital Modules",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_2",
|
||||||
|
"at": "2026-05-01T08:20:00Z",
|
||||||
|
"actorUserId": "user_ivan",
|
||||||
|
"actorName": "Иван Петров",
|
||||||
|
"action": "Создан invite",
|
||||||
|
"objectType": "invite",
|
||||||
|
"objectName": "analyst@romashka.ru",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "success",
|
||||||
|
"details": "Срок действия до 15.05.2026"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_3",
|
||||||
|
"at": "2026-04-30T17:10:00Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "Root Admin",
|
||||||
|
"action": "Создано deny-исключение",
|
||||||
|
"objectType": "access",
|
||||||
|
"objectName": "Лена / Task Manager",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "warning",
|
||||||
|
"details": "Индивидуальное правило перекрыло client grant."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit_4",
|
||||||
|
"at": "2026-04-30T16:00:00Z",
|
||||||
|
"actorUserId": "user_root",
|
||||||
|
"actorName": "Root Admin",
|
||||||
|
"action": "Ошибка синхронизации",
|
||||||
|
"objectType": "sync",
|
||||||
|
"objectName": "ООО Рога и Копыта / NodeDC",
|
||||||
|
"clientId": "client_romashka",
|
||||||
|
"result": "error",
|
||||||
|
"details": "Нет application binding."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 13 MiB |
|
After Width: | Height: | Size: 66 KiB |
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ServiceAccessException, ServiceGrant } from "../entities/access/types";
|
||||||
|
import type { Invite } from "../entities/invite/types";
|
||||||
|
import type { LauncherServiceView, Service } from "../entities/service/types";
|
||||||
|
import type { SyncStatus } from "../entities/sync/types";
|
||||||
|
import {
|
||||||
|
buildLauncherServices,
|
||||||
|
buildMe,
|
||||||
|
initialLauncherData,
|
||||||
|
profileOptions,
|
||||||
|
type LauncherData,
|
||||||
|
} from "../shared/api/mockApi";
|
||||||
|
import { loadPersistedLauncherData, persistLauncherData } from "../shared/api/storageApi";
|
||||||
|
import { AdminOverlay } from "../widgets/admin-overlay/AdminOverlay";
|
||||||
|
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||||
|
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||||
|
import { TopBar } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
|
export function LauncherApp() {
|
||||||
|
const [data, setData] = useState<LauncherData>(initialLauncherData);
|
||||||
|
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||||||
|
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
||||||
|
const [selectedServiceId, setSelectedServiceId] = useState<string | undefined>();
|
||||||
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
const [storageHydrated, setStorageHydrated] = useState(false);
|
||||||
|
|
||||||
|
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||||
|
const resolvedClientId = me.activeClientId;
|
||||||
|
const launcherServices = useMemo(
|
||||||
|
() => buildLauncherServices(data, activeProfileId, resolvedClientId),
|
||||||
|
[data, activeProfileId, resolvedClientId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!launcherServices.length) {
|
||||||
|
setSelectedServiceId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedServiceId && !launcherServices.some((service) => service.id === selectedServiceId)) {
|
||||||
|
setSelectedServiceId(undefined);
|
||||||
|
}
|
||||||
|
}, [launcherServices, selectedServiceId]);
|
||||||
|
|
||||||
|
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
loadPersistedLauncherData()
|
||||||
|
.then((persistedData) => {
|
||||||
|
if (isMounted && persistedData) {
|
||||||
|
setData(persistedData);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
setStorageHydrated(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageHydrated) return;
|
||||||
|
|
||||||
|
const saveTimer = window.setTimeout(() => {
|
||||||
|
persistLauncherData(data).catch((error: unknown) => {
|
||||||
|
console.warn(error instanceof Error ? error.message : "Не удалось сохранить состояние витрины");
|
||||||
|
});
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(saveTimer);
|
||||||
|
}, [data, storageHydrated]);
|
||||||
|
|
||||||
|
function handleProfileChange(userId: string) {
|
||||||
|
const profile = profileOptions.find((option) => option.userId === userId);
|
||||||
|
setActiveProfileId(userId);
|
||||||
|
setActiveClientId(profile?.defaultClientId ?? activeClientId);
|
||||||
|
setAdminOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLaunch(service: LauncherServiceView) {
|
||||||
|
if (!service.openUrl || !service.effectiveAccess.openEnabled) return;
|
||||||
|
window.open(service.openUrl, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServiceSelect(serviceId: string) {
|
||||||
|
setSelectedServiceId((current) => (current === serviceId ? undefined : serviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateGrant(grant: Omit<ServiceGrant, "id" | "status" | "createdAt" | "updatedAt">) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
grants: [
|
||||||
|
...current.grants,
|
||||||
|
{
|
||||||
|
...grant,
|
||||||
|
id: `grant_mock_${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateDenyException(exception: Omit<ServiceAccessException, "id" | "type" | "createdAt" | "updatedAt">) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
exceptions: [
|
||||||
|
...current.exceptions.filter(
|
||||||
|
(item) => !(item.serviceId === exception.serviceId && item.userId === exception.userId && item.type === "deny")
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...exception,
|
||||||
|
id: `exception_mock_${Date.now()}`,
|
||||||
|
type: "deny",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveException(exceptionId: string) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
exceptions: current.exceptions.filter((exception) => exception.id !== exceptionId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateInvite(invite: Pick<Invite, "clientId" | "email" | "role">) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
invites: [
|
||||||
|
{
|
||||||
|
...invite,
|
||||||
|
id: `invite_mock_${Date.now()}`,
|
||||||
|
invitedByUserId: me.user.id,
|
||||||
|
token: `mock-${Date.now()}`,
|
||||||
|
expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: "created",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
...current.invites,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRetrySync(syncId: string) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
syncStatuses: current.syncStatuses.map((sync): SyncStatus =>
|
||||||
|
sync.id === syncId
|
||||||
|
? {
|
||||||
|
...sync,
|
||||||
|
state: "pending",
|
||||||
|
error: null,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: sync
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.map((service) =>
|
||||||
|
service.id === serviceId
|
||||||
|
? {
|
||||||
|
...service,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: service
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateService() {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
setData((current) => {
|
||||||
|
const nextOrder = Math.max(0, ...current.services.map((service) => service.order)) + 10;
|
||||||
|
const id = `service_mock_${Date.now()}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
services: [
|
||||||
|
...current.services,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
slug: `new-service-${current.services.length + 1}`,
|
||||||
|
title: "New Service",
|
||||||
|
subtitle: "Новый сервис",
|
||||||
|
description: "Описание сервиса для витрины.",
|
||||||
|
fullDescription: "Заполните описание, медиа и ссылку запуска в редакторе контента.",
|
||||||
|
url: "https://service.handhdc.ru",
|
||||||
|
launchUrl: "https://service.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#F7F8F4",
|
||||||
|
fallbackGradient: "linear-gradient(135deg, rgba(247, 248, 244, 0.72), rgba(36, 37, 42, 0.9) 52%, #090B0F 88%)",
|
||||||
|
coverMediaSource: "url",
|
||||||
|
coverMediaKind: "image",
|
||||||
|
ambientMediaSource: "url",
|
||||||
|
ambientMediaKind: "gif",
|
||||||
|
status: "hidden",
|
||||||
|
order: nextOrder,
|
||||||
|
authentikApplicationSlug: `new-service-${current.services.length + 1}`,
|
||||||
|
authentikGroupName: `service-new-${current.services.length + 1}`,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteService(serviceId: string) {
|
||||||
|
setData((current) => ({
|
||||||
|
...current,
|
||||||
|
services: current.services.filter((service) => service.id !== serviceId),
|
||||||
|
grants: current.grants.filter((grant) => grant.serviceId !== serviceId),
|
||||||
|
exceptions: current.exceptions.filter((exception) => exception.serviceId !== serviceId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="launcher-app">
|
||||||
|
<TopBar
|
||||||
|
me={me}
|
||||||
|
clients={data.clients}
|
||||||
|
profileOptions={profileOptions}
|
||||||
|
activeProfileId={activeProfileId}
|
||||||
|
activeClientId={resolvedClientId}
|
||||||
|
adminOpen={adminOpen}
|
||||||
|
onProfileChange={handleProfileChange}
|
||||||
|
onClientChange={setActiveClientId}
|
||||||
|
onOpenAdmin={() => setAdminOpen(true)}
|
||||||
|
onOpenShowcase={() => setAdminOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="launcher-main">
|
||||||
|
<ServiceStage service={selectedService} hasServices={launcherServices.length > 0} onLaunch={handleLaunch} />
|
||||||
|
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||||
|
<AdminOverlay
|
||||||
|
data={data}
|
||||||
|
me={me}
|
||||||
|
activeClientId={resolvedClientId}
|
||||||
|
onClose={() => setAdminOpen(false)}
|
||||||
|
onCreateGrant={handleCreateGrant}
|
||||||
|
onCreateDenyException={handleCreateDenyException}
|
||||||
|
onRemoveException={handleRemoveException}
|
||||||
|
onCreateInvite={handleCreateInvite}
|
||||||
|
onRetrySync={handleRetrySync}
|
||||||
|
onUpdateService={handleUpdateService}
|
||||||
|
onCreateService={handleCreateService}
|
||||||
|
onDeleteService={handleDeleteService}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeEffectiveAccess } from "./computeEffectiveAccess";
|
||||||
|
import type { Client } from "../client/types";
|
||||||
|
import type { Service } from "../service/types";
|
||||||
|
import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
|
||||||
|
import type { ServiceAccessException, ServiceGrant } from "./types";
|
||||||
|
|
||||||
|
const client: Client = {
|
||||||
|
id: "client_a",
|
||||||
|
type: "company",
|
||||||
|
name: "ООО Тест",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const user: LauncherUser = {
|
||||||
|
id: "user_a",
|
||||||
|
name: "Пользователь",
|
||||||
|
email: "user@example.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const membership: ClientMembership = {
|
||||||
|
id: "membership_a",
|
||||||
|
clientId: client.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: "member",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const group: ClientGroup = {
|
||||||
|
id: "group_a",
|
||||||
|
clientId: client.id,
|
||||||
|
name: "Группа",
|
||||||
|
memberIds: [user.id],
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const service: Service = {
|
||||||
|
id: "service_a",
|
||||||
|
slug: "service-a",
|
||||||
|
title: "Service A",
|
||||||
|
description: "Demo service",
|
||||||
|
url: "https://example.ru",
|
||||||
|
status: "active",
|
||||||
|
order: 1,
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
client,
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
userGroups: [group],
|
||||||
|
service,
|
||||||
|
grants: [] as ServiceGrant[],
|
||||||
|
exceptions: [] as ServiceAccessException[],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("computeEffectiveAccess", () => {
|
||||||
|
it("returns false when client is suspended", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
client: { ...client, status: "suspended" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("Клиент");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user is blocked", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
user: { ...user, globalStatus: "blocked" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toContain("Пользователь");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no grant exists", () => {
|
||||||
|
const result = computeEffectiveAccess(baseInput);
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true from client grant", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
grants: [grant("client", client.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.source).toBe("client");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true from group grant", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
grants: [grant("group", group.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.source).toBe("group");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true from user grant", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
grants: [grant("user", user.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.source).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deny exception overrides user grant", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
grants: [grant("user", user.id)],
|
||||||
|
exceptions: [deny()],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.source).toBe("exception");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintenance service is visible but openEnabled is false", () => {
|
||||||
|
const result = computeEffectiveAccess({
|
||||||
|
...baseInput,
|
||||||
|
service: { ...service, status: "maintenance" },
|
||||||
|
grants: [grant("client", client.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.visible).toBe(true);
|
||||||
|
expect(result.openEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function grant(targetType: ServiceGrant["targetType"], targetId: string): ServiceGrant {
|
||||||
|
return {
|
||||||
|
id: `grant_${targetType}`,
|
||||||
|
serviceId: service.id,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
appRole: "member",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deny(): ServiceAccessException {
|
||||||
|
return {
|
||||||
|
id: "exception_deny",
|
||||||
|
serviceId: service.id,
|
||||||
|
userId: user.id,
|
||||||
|
type: "deny",
|
||||||
|
reason: "Тестовый deny",
|
||||||
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import type { Client } from "../client/types";
|
||||||
|
import type { Service } from "../service/types";
|
||||||
|
import type { ClientGroup, ClientMembership, LauncherUser } from "../user/types";
|
||||||
|
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "./types";
|
||||||
|
|
||||||
|
export function computeEffectiveAccess(input: {
|
||||||
|
client: Client;
|
||||||
|
user: LauncherUser;
|
||||||
|
membership: ClientMembership;
|
||||||
|
userGroups: ClientGroup[];
|
||||||
|
service: Service;
|
||||||
|
grants: ServiceGrant[];
|
||||||
|
exceptions: ServiceAccessException[];
|
||||||
|
}): EffectiveAccessResult {
|
||||||
|
if (input.client.status === "suspended" || input.client.status === "expired") {
|
||||||
|
return blocked(input, "Клиент приостановлен или срок доступа истёк");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.user.globalStatus === "blocked" || input.membership.status === "disabled") {
|
||||||
|
return blocked(input, "Пользователь заблокирован или отключён внутри клиента");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.service.status === "disabled") {
|
||||||
|
return blocked(input, "Сервис отключён");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.service.status === "hidden") {
|
||||||
|
return blocked(input, "Сервис скрыт");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deny = input.exceptions.find(
|
||||||
|
(item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "deny"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deny) {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: false,
|
||||||
|
visible: false,
|
||||||
|
openEnabled: false,
|
||||||
|
source: "exception",
|
||||||
|
sourceId: deny.id,
|
||||||
|
reason: "Доступ отключён индивидуальным исключением",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allow = input.exceptions.find(
|
||||||
|
(item) => item.serviceId === input.service.id && item.userId === input.user.id && item.type === "allow"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allow) {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: true,
|
||||||
|
visible: true,
|
||||||
|
openEnabled: input.service.status === "active",
|
||||||
|
source: "exception",
|
||||||
|
sourceId: allow.id,
|
||||||
|
reason: "Доступ выдан индивидуальным allow-исключением",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGrant = input.grants.find(
|
||||||
|
(grant) =>
|
||||||
|
grant.serviceId === input.service.id &&
|
||||||
|
grant.targetType === "user" &&
|
||||||
|
grant.targetId === input.user.id &&
|
||||||
|
grant.status === "active"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userGrant) {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: true,
|
||||||
|
visible: true,
|
||||||
|
openEnabled: input.service.status === "active",
|
||||||
|
appRole: userGrant.appRole,
|
||||||
|
source: "user",
|
||||||
|
sourceId: userGrant.id,
|
||||||
|
reason: "Доступ выдан пользователю напрямую",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIds = input.userGroups.map((group) => group.id);
|
||||||
|
const groupGrant = input.grants.find(
|
||||||
|
(grant) =>
|
||||||
|
grant.serviceId === input.service.id &&
|
||||||
|
grant.targetType === "group" &&
|
||||||
|
groupIds.includes(grant.targetId) &&
|
||||||
|
grant.status === "active"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupGrant) {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: true,
|
||||||
|
visible: true,
|
||||||
|
openEnabled: input.service.status === "active",
|
||||||
|
appRole: groupGrant.appRole,
|
||||||
|
source: "group",
|
||||||
|
sourceId: groupGrant.id,
|
||||||
|
reason: "Доступ выдан группе пользователя",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientGrant = input.grants.find(
|
||||||
|
(grant) =>
|
||||||
|
grant.serviceId === input.service.id &&
|
||||||
|
grant.targetType === "client" &&
|
||||||
|
grant.targetId === input.client.id &&
|
||||||
|
grant.status === "active"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientGrant) {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: true,
|
||||||
|
visible: true,
|
||||||
|
openEnabled: input.service.status === "active",
|
||||||
|
appRole: clientGrant.appRole,
|
||||||
|
source: "client",
|
||||||
|
sourceId: clientGrant.id,
|
||||||
|
reason: "Доступ выдан всему клиенту",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocked(input, "Доступ к сервису не выдан");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocked(input: {
|
||||||
|
service: Service;
|
||||||
|
user: LauncherUser;
|
||||||
|
}, reason: string): EffectiveAccessResult {
|
||||||
|
return {
|
||||||
|
serviceId: input.service.id,
|
||||||
|
userId: input.user.id,
|
||||||
|
allowed: false,
|
||||||
|
visible: false,
|
||||||
|
openEnabled: false,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
export type ServiceGrantTargetType = "client" | "group" | "user";
|
||||||
|
export type ServiceAppRole = "viewer" | "member" | "admin" | "owner";
|
||||||
|
export type ServiceGrantStatus = "active" | "disabled";
|
||||||
|
|
||||||
|
export interface ServiceGrant {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
targetType: ServiceGrantTargetType;
|
||||||
|
targetId: string;
|
||||||
|
appRole: ServiceAppRole;
|
||||||
|
status: ServiceGrantStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceAccessExceptionType = "deny" | "allow";
|
||||||
|
|
||||||
|
export interface ServiceAccessException {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
userId: string;
|
||||||
|
type: ServiceAccessExceptionType;
|
||||||
|
reason?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectiveAccessResult {
|
||||||
|
serviceId: string;
|
||||||
|
userId: string;
|
||||||
|
allowed: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
openEnabled: boolean;
|
||||||
|
appRole?: ServiceAppRole;
|
||||||
|
reason: string;
|
||||||
|
source?: ServiceGrantTargetType | "exception";
|
||||||
|
sourceId?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
at: string;
|
||||||
|
actorUserId: string;
|
||||||
|
actorName: string;
|
||||||
|
action: string;
|
||||||
|
objectType: string;
|
||||||
|
objectName: string;
|
||||||
|
clientId?: string | null;
|
||||||
|
result: "success" | "warning" | "error";
|
||||||
|
details?: string | null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type ClientType = "company" | "person";
|
||||||
|
export type ClientStatus = "active" | "suspended" | "demo" | "expired";
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
type: ClientType;
|
||||||
|
name: string;
|
||||||
|
legalName?: string | null;
|
||||||
|
status: ClientStatus;
|
||||||
|
demoEndsAt?: string | null;
|
||||||
|
contactName?: string | null;
|
||||||
|
contactEmail?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { ClientMembershipRole } from "../user/types";
|
||||||
|
|
||||||
|
export type InviteStatus = "created" | "sent" | "accepted" | "expired" | "revoked";
|
||||||
|
|
||||||
|
export interface Invite {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
email: string;
|
||||||
|
role: ClientMembershipRole;
|
||||||
|
invitedByUserId: string;
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
status: InviteStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import type { EffectiveAccessResult, ServiceAppRole } from "../access/types";
|
||||||
|
|
||||||
|
export type ServiceStatus = "active" | "maintenance" | "hidden" | "disabled";
|
||||||
|
export type MediaKind = "image" | "video" | "gif" | "gradient";
|
||||||
|
export type ServiceMediaSource = "url" | "file";
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
description: string;
|
||||||
|
fullDescription?: string | null;
|
||||||
|
url: string;
|
||||||
|
launchUrl?: string | null;
|
||||||
|
iconUrl?: string | null;
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
coverMediaKind?: MediaKind | null;
|
||||||
|
coverMediaSource?: ServiceMediaSource | null;
|
||||||
|
coverMediaFileName?: string | null;
|
||||||
|
previewVideoUrl?: string | null;
|
||||||
|
ambientVideoUrl?: string | null;
|
||||||
|
ambientMediaKind?: MediaKind | null;
|
||||||
|
ambientMediaSource?: ServiceMediaSource | null;
|
||||||
|
ambientMediaFileName?: string | null;
|
||||||
|
accentColor?: string | null;
|
||||||
|
fallbackGradient?: string | null;
|
||||||
|
status: ServiceStatus;
|
||||||
|
order: number;
|
||||||
|
authentikApplicationSlug?: string | null;
|
||||||
|
authentikGroupName?: string | null;
|
||||||
|
isAvailableForAllNewClients?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceMedia {
|
||||||
|
kind: MediaKind;
|
||||||
|
url?: string;
|
||||||
|
posterUrl?: string;
|
||||||
|
fallbackGradient?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherServiceView {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
description: string;
|
||||||
|
fullDescription?: string | null;
|
||||||
|
status: ServiceStatus;
|
||||||
|
userAccess: "allowed" | "denied";
|
||||||
|
appRole?: ServiceAppRole;
|
||||||
|
openUrl?: string | null;
|
||||||
|
accentColor?: string | null;
|
||||||
|
media: {
|
||||||
|
icon?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
coverImage?: string | null;
|
||||||
|
coverKind?: MediaKind | null;
|
||||||
|
coverSource?: ServiceMediaSource | null;
|
||||||
|
coverFileName?: string | null;
|
||||||
|
previewVideo?: string | null;
|
||||||
|
ambientVideo?: string | null;
|
||||||
|
ambientKind?: MediaKind | null;
|
||||||
|
ambientSource?: ServiceMediaSource | null;
|
||||||
|
ambientFileName?: string | null;
|
||||||
|
fallbackGradient?: string | null;
|
||||||
|
};
|
||||||
|
effectiveAccess: EffectiveAccessResult;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
export type SyncTarget = "authentik" | "task_manager" | "nodedc" | "service";
|
||||||
|
export type SyncState = "synced" | "pending" | "error" | "disabled";
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
id: string;
|
||||||
|
objectId: string;
|
||||||
|
objectName: string;
|
||||||
|
objectType: "client" | "user" | "group" | "service" | "grant" | "invite";
|
||||||
|
target: SyncTarget;
|
||||||
|
state: SyncState;
|
||||||
|
lastSyncAt?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
export type LauncherGlobalRole =
|
||||||
|
| "root_admin"
|
||||||
|
| "support_admin"
|
||||||
|
| "client_owner"
|
||||||
|
| "client_admin"
|
||||||
|
| "member";
|
||||||
|
|
||||||
|
export type LauncherUserStatus = "invited" | "active" | "blocked";
|
||||||
|
|
||||||
|
export interface LauncherUser {
|
||||||
|
id: string;
|
||||||
|
authentikUserId?: string | null;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
globalStatus: LauncherUserStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientMembershipRole = "client_owner" | "client_admin" | "member";
|
||||||
|
export type ClientMembershipStatus = "active" | "disabled";
|
||||||
|
|
||||||
|
export interface ClientMembership {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
userId: string;
|
||||||
|
role: ClientMembershipRole;
|
||||||
|
status: ClientMembershipStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientGroup {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
memberIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { LauncherApp } from "./app/LauncherApp";
|
||||||
|
import "./styles/globals.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<LauncherApp />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { computeEffectiveAccess } from "../../entities/access/computeEffectiveAccess";
|
||||||
|
import type { EffectiveAccessResult, ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||||
|
import type { Client } from "../../entities/client/types";
|
||||||
|
import type { Invite } from "../../entities/invite/types";
|
||||||
|
import type { LauncherServiceView, Service } from "../../entities/service/types";
|
||||||
|
import type { SyncStatus } from "../../entities/sync/types";
|
||||||
|
import type {
|
||||||
|
ClientGroup,
|
||||||
|
ClientMembership,
|
||||||
|
ClientMembershipRole,
|
||||||
|
LauncherGlobalRole,
|
||||||
|
LauncherUser,
|
||||||
|
} from "../../entities/user/types";
|
||||||
|
import { resolveLauncherRole, resolvePermissions, type LauncherPermissions } from "../lib/permissions";
|
||||||
|
import {
|
||||||
|
mockAuditEvents,
|
||||||
|
mockClients,
|
||||||
|
mockExceptions,
|
||||||
|
mockGrants,
|
||||||
|
mockGroups,
|
||||||
|
mockInvites,
|
||||||
|
mockMemberships,
|
||||||
|
mockServices,
|
||||||
|
mockSyncStatuses,
|
||||||
|
mockUsers,
|
||||||
|
} from "./mockData";
|
||||||
|
|
||||||
|
export interface AuthentikClaimsMock {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
groups: string[];
|
||||||
|
activeClientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
user: Pick<LauncherUser, "id" | "authentikUserId" | "name" | "email" | "avatarUrl">;
|
||||||
|
launcherRole: LauncherGlobalRole;
|
||||||
|
memberships: Array<{
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
role: ClientMembershipRole;
|
||||||
|
status: ClientMembership["status"];
|
||||||
|
}>;
|
||||||
|
activeClientId: string;
|
||||||
|
permissions: LauncherPermissions;
|
||||||
|
mockAuthentikClaims: AuthentikClaimsMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LauncherData {
|
||||||
|
clients: Client[];
|
||||||
|
users: LauncherUser[];
|
||||||
|
memberships: ClientMembership[];
|
||||||
|
groups: ClientGroup[];
|
||||||
|
services: Service[];
|
||||||
|
grants: ServiceGrant[];
|
||||||
|
exceptions: ServiceAccessException[];
|
||||||
|
invites: Invite[];
|
||||||
|
syncStatuses: SyncStatus[];
|
||||||
|
auditEvents: typeof mockAuditEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileOption {
|
||||||
|
userId: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
defaultClientId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessMatrixCell {
|
||||||
|
userId: string;
|
||||||
|
serviceId: string;
|
||||||
|
effectiveAccess: EffectiveAccessResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessMatrix {
|
||||||
|
client: Client;
|
||||||
|
users: LauncherUser[];
|
||||||
|
groups: ClientGroup[];
|
||||||
|
services: Service[];
|
||||||
|
cells: AccessMatrixCell[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialLauncherData: LauncherData = {
|
||||||
|
clients: mockClients,
|
||||||
|
users: mockUsers,
|
||||||
|
memberships: mockMemberships,
|
||||||
|
groups: mockGroups,
|
||||||
|
services: mockServices,
|
||||||
|
grants: mockGrants,
|
||||||
|
exceptions: mockExceptions,
|
||||||
|
invites: mockInvites,
|
||||||
|
syncStatuses: mockSyncStatuses,
|
||||||
|
auditEvents: mockAuditEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const profileOptions: ProfileOption[] = [
|
||||||
|
{
|
||||||
|
userId: "user_root",
|
||||||
|
label: "Root Admin",
|
||||||
|
description: "Полный каталог и все клиенты",
|
||||||
|
defaultClientId: "client_romashka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_ivan",
|
||||||
|
label: "Client Owner",
|
||||||
|
description: "Иван, владелец Ромашки и админ демо-клиента",
|
||||||
|
defaultClientId: "client_romashka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_vera",
|
||||||
|
label: "Client Admin",
|
||||||
|
description: "Вера, админ ООО Ромашка",
|
||||||
|
defaultClientId: "client_romashka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_vasya",
|
||||||
|
label: "Member",
|
||||||
|
description: "Василий, обычный участник",
|
||||||
|
defaultClientId: "client_romashka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_lena",
|
||||||
|
label: "Member + deny",
|
||||||
|
description: "Лена, участник с deny-исключением",
|
||||||
|
defaultClientId: "client_romashka",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "user_maria",
|
||||||
|
label: "Client Owner demo",
|
||||||
|
description: "Мария, владелец демо-клиента",
|
||||||
|
defaultClientId: "client_roga_kopyta",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildMe(data: LauncherData, userId: string, requestedClientId?: string): MeResponse {
|
||||||
|
const user = getUser(data, userId);
|
||||||
|
const isRoot = user.id === "user_root";
|
||||||
|
const availableMemberships = isRoot
|
||||||
|
? data.clients.map((client) => ({
|
||||||
|
clientId: client.id,
|
||||||
|
clientName: client.name,
|
||||||
|
role: "client_owner" as const,
|
||||||
|
status: "active" as const,
|
||||||
|
}))
|
||||||
|
: data.memberships
|
||||||
|
.filter((membership) => membership.userId === user.id)
|
||||||
|
.map((membership) => ({
|
||||||
|
clientId: membership.clientId,
|
||||||
|
clientName: getClient(data, membership.clientId).name,
|
||||||
|
role: membership.role,
|
||||||
|
status: membership.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fallbackClientId =
|
||||||
|
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
|
||||||
|
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
|
||||||
|
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
|
||||||
|
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
|
||||||
|
const launcherRole = resolveLauncherRole({ isRoot, membershipRole: activeMembership?.role });
|
||||||
|
const permissions = resolvePermissions({
|
||||||
|
launcherRole,
|
||||||
|
membershipStatus: activeMembership?.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
authentikUserId: user.authentikUserId,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
},
|
||||||
|
launcherRole,
|
||||||
|
memberships: availableMemberships,
|
||||||
|
activeClientId,
|
||||||
|
permissions,
|
||||||
|
mockAuthentikClaims: {
|
||||||
|
sub: user.authentikUserId ?? user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
groups: buildMockGroups(data, user.id, activeClientId, launcherRole),
|
||||||
|
activeClientId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLauncherServices(data: LauncherData, userId: string, activeClientId: string): LauncherServiceView[] {
|
||||||
|
const me = buildMe(data, userId, activeClientId);
|
||||||
|
const user = getUser(data, userId);
|
||||||
|
const client = getClient(data, activeClientId);
|
||||||
|
const membership = getRuntimeMembership(data, user.id, activeClientId, me.launcherRole === "root_admin");
|
||||||
|
const userGroups = getUserGroups(data, user.id, activeClientId);
|
||||||
|
const isRoot = me.launcherRole === "root_admin";
|
||||||
|
|
||||||
|
return data.services
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((service) => {
|
||||||
|
const effectiveAccess = computeEffectiveAccess({
|
||||||
|
client,
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
userGroups,
|
||||||
|
service,
|
||||||
|
grants: data.grants,
|
||||||
|
exceptions: data.exceptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: service.id,
|
||||||
|
slug: service.slug,
|
||||||
|
title: service.title,
|
||||||
|
subtitle: service.subtitle,
|
||||||
|
description: service.description,
|
||||||
|
fullDescription: service.fullDescription,
|
||||||
|
status: service.status,
|
||||||
|
userAccess: effectiveAccess.allowed ? ("allowed" as const) : ("denied" as const),
|
||||||
|
appRole: effectiveAccess.appRole,
|
||||||
|
openUrl: effectiveAccess.openEnabled ? service.launchUrl ?? service.url : null,
|
||||||
|
accentColor: service.accentColor,
|
||||||
|
media: {
|
||||||
|
icon: service.iconUrl,
|
||||||
|
thumbnail: service.coverImageUrl,
|
||||||
|
coverImage: service.coverImageUrl,
|
||||||
|
coverKind: service.coverMediaKind,
|
||||||
|
coverSource: service.coverMediaSource,
|
||||||
|
coverFileName: service.coverMediaFileName,
|
||||||
|
previewVideo: service.previewVideoUrl,
|
||||||
|
ambientVideo: service.ambientVideoUrl,
|
||||||
|
ambientKind: service.ambientMediaKind,
|
||||||
|
ambientSource: service.ambientMediaSource,
|
||||||
|
ambientFileName: service.ambientMediaFileName,
|
||||||
|
fallbackGradient: service.fallbackGradient,
|
||||||
|
},
|
||||||
|
effectiveAccess,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((service) => isRoot || service.effectiveAccess.visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccessMatrix(data: LauncherData, clientId: string, includeAllServices: boolean): AccessMatrix {
|
||||||
|
const client = getClient(data, clientId);
|
||||||
|
const memberships = data.memberships.filter((membership) => membership.clientId === clientId);
|
||||||
|
const users = memberships.map((membership) => getUser(data, membership.userId));
|
||||||
|
const groups = data.groups.filter((group) => group.clientId === clientId);
|
||||||
|
const clientServiceIds = new Set(
|
||||||
|
data.grants
|
||||||
|
.filter((grant) => grant.targetType === "client" && grant.targetId === clientId && grant.status === "active")
|
||||||
|
.map((grant) => grant.serviceId)
|
||||||
|
);
|
||||||
|
const services = data.services
|
||||||
|
.filter((service) => includeAllServices || clientServiceIds.has(service.id) || service.status === "active")
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
const cells = users.flatMap((user) => {
|
||||||
|
const membership = memberships.find((item) => item.userId === user.id) ?? getRuntimeMembership(data, user.id, clientId);
|
||||||
|
const userGroups = getUserGroups(data, user.id, clientId);
|
||||||
|
|
||||||
|
return services.map((service) => ({
|
||||||
|
userId: user.id,
|
||||||
|
serviceId: service.id,
|
||||||
|
effectiveAccess: computeEffectiveAccess({
|
||||||
|
client,
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
userGroups,
|
||||||
|
service,
|
||||||
|
grants: data.grants,
|
||||||
|
exceptions: data.exceptions,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { client, users, groups, services, cells };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClient(data: LauncherData, clientId: string): Client {
|
||||||
|
const client = data.clients.find((item) => item.id === clientId);
|
||||||
|
if (!client) throw new Error(`Unknown client: ${clientId}`);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser(data: LauncherData, userId: string): LauncherUser {
|
||||||
|
const user = data.users.find((item) => item.id === userId);
|
||||||
|
if (!user) throw new Error(`Unknown user: ${userId}`);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getService(data: LauncherData, serviceId: string): Service {
|
||||||
|
const service = data.services.find((item) => item.id === serviceId);
|
||||||
|
if (!service) throw new Error(`Unknown service: ${serviceId}`);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientUsers(data: LauncherData, clientId: string): LauncherUser[] {
|
||||||
|
const userIds = new Set(data.memberships.filter((membership) => membership.clientId === clientId).map((item) => item.userId));
|
||||||
|
return data.users.filter((user) => userIds.has(user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserGroups(data: LauncherData, userId: string, clientId: string): ClientGroup[] {
|
||||||
|
return data.groups.filter((group) => group.clientId === clientId && group.memberIds.includes(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeMembership(
|
||||||
|
data: LauncherData,
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
allowVirtualRoot = false
|
||||||
|
): ClientMembership {
|
||||||
|
const membership = data.memberships.find((item) => item.userId === userId && item.clientId === clientId);
|
||||||
|
if (membership) return membership;
|
||||||
|
|
||||||
|
if (allowVirtualRoot) {
|
||||||
|
return {
|
||||||
|
id: `virtual_root_${clientId}`,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
role: "client_owner",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-05-01T09:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T09:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `missing_${clientId}_${userId}`,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
role: "member",
|
||||||
|
status: "disabled",
|
||||||
|
createdAt: "2026-05-01T09:00:00Z",
|
||||||
|
updatedAt: "2026-05-01T09:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockGroups(
|
||||||
|
data: LauncherData,
|
||||||
|
userId: string,
|
||||||
|
activeClientId: string,
|
||||||
|
launcherRole: LauncherGlobalRole
|
||||||
|
): string[] {
|
||||||
|
const groups = getUserGroups(data, userId, activeClientId).map((group) => `client:${activeClientId}:group:${group.name}`);
|
||||||
|
return [`launcher:${launcherRole}`, `client:${activeClientId}`, ...groups];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,448 @@
|
||||||
|
import type { AuditEvent } from "../../entities/audit/types";
|
||||||
|
import type { Client } from "../../entities/client/types";
|
||||||
|
import type { Invite } from "../../entities/invite/types";
|
||||||
|
import type { Service } from "../../entities/service/types";
|
||||||
|
import type { SyncStatus } from "../../entities/sync/types";
|
||||||
|
import type { ClientGroup, ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||||
|
import type { ServiceAccessException, ServiceGrant } from "../../entities/access/types";
|
||||||
|
|
||||||
|
const now = "2026-05-01T09:00:00Z";
|
||||||
|
|
||||||
|
export const mockClients: Client[] = [
|
||||||
|
{
|
||||||
|
id: "client_romashka",
|
||||||
|
type: "company",
|
||||||
|
name: "ООО Ромашка",
|
||||||
|
legalName: "ООО Ромашка",
|
||||||
|
status: "active",
|
||||||
|
demoEndsAt: null,
|
||||||
|
contactName: "Иван Петров",
|
||||||
|
contactEmail: "ivan@romashka.ru",
|
||||||
|
notes: "Основной demo-клиент для проверки Task Manager, NodeDC и deny-исключений.",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client_roga_kopyta",
|
||||||
|
type: "company",
|
||||||
|
name: "ООО Рога и Копыта",
|
||||||
|
legalName: "ООО Рога и Копыта",
|
||||||
|
status: "demo",
|
||||||
|
demoEndsAt: "2026-06-01T00:00:00Z",
|
||||||
|
contactName: "Мария Иванова",
|
||||||
|
contactEmail: "maria@example.ru",
|
||||||
|
notes: "Клиент на демо-доступе, подключены только базовые сервисы.",
|
||||||
|
createdAt: "2026-04-10T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client_private_architect",
|
||||||
|
type: "person",
|
||||||
|
name: "Илья Архитектор",
|
||||||
|
legalName: null,
|
||||||
|
status: "suspended",
|
||||||
|
demoEndsAt: "2026-04-20T00:00:00Z",
|
||||||
|
contactName: "Илья Архитектор",
|
||||||
|
contactEmail: "ilya@example.ru",
|
||||||
|
notes: "Пример приостановленного частного клиента.",
|
||||||
|
createdAt: "2026-03-14T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockUsers: LauncherUser[] = [
|
||||||
|
{
|
||||||
|
id: "user_root",
|
||||||
|
authentikUserId: "ak-root",
|
||||||
|
name: "Root Admin",
|
||||||
|
email: "root@nodedc.local",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_ivan",
|
||||||
|
authentikUserId: "ak-ivan",
|
||||||
|
name: "Иван Петров",
|
||||||
|
email: "ivan@romashka.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_vera",
|
||||||
|
authentikUserId: "ak-vera",
|
||||||
|
name: "Вера Соколова",
|
||||||
|
email: "vera@romashka.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-02T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_vasya",
|
||||||
|
authentikUserId: "ak-vasya",
|
||||||
|
name: "Василий Орлов",
|
||||||
|
email: "vasya@romashka.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-05T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_lena",
|
||||||
|
authentikUserId: "ak-lena",
|
||||||
|
name: "Лена Волкова",
|
||||||
|
email: "lena@romashka.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-08T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_maria",
|
||||||
|
authentikUserId: "ak-maria",
|
||||||
|
name: "Мария Иванова",
|
||||||
|
email: "maria@example.ru",
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: "2026-04-10T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "user_blocked",
|
||||||
|
authentikUserId: "ak-blocked",
|
||||||
|
name: "Олег Заблокирован",
|
||||||
|
email: "oleg@romashka.ru",
|
||||||
|
globalStatus: "blocked",
|
||||||
|
createdAt: "2026-04-12T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMemberships: ClientMembership[] = [
|
||||||
|
membership("mem_ivan_romashka", "client_romashka", "user_ivan", "client_owner"),
|
||||||
|
membership("mem_vera_romashka", "client_romashka", "user_vera", "client_admin"),
|
||||||
|
membership("mem_vasya_romashka", "client_romashka", "user_vasya", "member"),
|
||||||
|
membership("mem_lena_romashka", "client_romashka", "user_lena", "member"),
|
||||||
|
membership("mem_blocked_romashka", "client_romashka", "user_blocked", "member", "disabled"),
|
||||||
|
membership("mem_maria_roga", "client_roga_kopyta", "user_maria", "client_owner"),
|
||||||
|
membership("mem_ivan_roga", "client_roga_kopyta", "user_ivan", "client_admin"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockGroups: ClientGroup[] = [
|
||||||
|
group("group_romashka_leads", "client_romashka", "Руководство", "Собственники и руководители клиента.", [
|
||||||
|
"user_ivan",
|
||||||
|
"user_vera",
|
||||||
|
]),
|
||||||
|
group("group_romashka_accounting", "client_romashka", "Бухгалтерия", "1C и финансовые сценарии.", [
|
||||||
|
"user_lena",
|
||||||
|
]),
|
||||||
|
group("group_romashka_ops", "client_romashka", "Операторы", "Ежедневная работа в задачах и тендерах.", [
|
||||||
|
"user_vasya",
|
||||||
|
"user_lena",
|
||||||
|
]),
|
||||||
|
group("group_roga_demo", "client_roga_kopyta", "Демо-команда", "Пилотный контур клиента.", ["user_maria", "user_ivan"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockServices: Service[] = [
|
||||||
|
{
|
||||||
|
id: "service_nodedc",
|
||||||
|
slug: "nodedc",
|
||||||
|
title: "NodeDC",
|
||||||
|
subtitle: "Агентная платформа",
|
||||||
|
description: "Сборка, запуск и мониторинг агентных workflow.",
|
||||||
|
fullDescription:
|
||||||
|
"NodeDC используется для настройки агентных процессов, визуальной оркестрации, интеграций и runtime-мониторинга.",
|
||||||
|
url: "https://dev.handhdc.ru",
|
||||||
|
launchUrl: "https://dev.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#B5FF5A",
|
||||||
|
fallbackGradient: "linear-gradient(128deg, rgba(181, 255, 90, 0.84), rgba(37, 58, 36, 0.86) 42%, #0A0D10 82%)",
|
||||||
|
status: "active",
|
||||||
|
order: 10,
|
||||||
|
authentikApplicationSlug: "nodedc",
|
||||||
|
authentikGroupName: "service-nodedc",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_task_manager",
|
||||||
|
slug: "task-manager",
|
||||||
|
title: "Task Manager",
|
||||||
|
subtitle: "Операционный слой",
|
||||||
|
description: "Задачи, контуры предприятия, процессы и AI-функции поверх задачника.",
|
||||||
|
fullDescription: "Task Manager основан на архитектуре Plane и расширен AI-функциями NODE.DC.",
|
||||||
|
url: "https://tasks.handhdc.ru",
|
||||||
|
launchUrl: "https://tasks.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#D7C8FF",
|
||||||
|
fallbackGradient: "linear-gradient(132deg, rgba(215, 200, 255, 0.82), rgba(51, 41, 79, 0.9) 46%, #0B0D10 84%)",
|
||||||
|
status: "active",
|
||||||
|
order: 20,
|
||||||
|
authentikApplicationSlug: "task-manager",
|
||||||
|
authentikGroupName: "service-task-manager",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_1c",
|
||||||
|
slug: "1c-assistant",
|
||||||
|
title: "1C Assistant",
|
||||||
|
subtitle: "Бухгалтерский ассистент",
|
||||||
|
description: "Вопросы к 1С, точные выборки и доказательная навигация по данным.",
|
||||||
|
fullDescription: "Ассистент для бухгалтерских запросов, анализа операций, остатков и документов.",
|
||||||
|
url: "https://1c.handhdc.ru",
|
||||||
|
launchUrl: "https://1c.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#8FD7FF",
|
||||||
|
fallbackGradient: "linear-gradient(126deg, rgba(143, 215, 255, 0.8), rgba(32, 61, 80, 0.9) 44%, #080B0F 84%)",
|
||||||
|
status: "maintenance",
|
||||||
|
order: 30,
|
||||||
|
authentikApplicationSlug: "1c-assistant",
|
||||||
|
authentikGroupName: "service-1c-assistant",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_tender",
|
||||||
|
slug: "tender-agent",
|
||||||
|
title: "Tender Agent",
|
||||||
|
subtitle: "Госзакупки и тендеры",
|
||||||
|
description: "Поиск, анализ и подготовка тендерных решений.",
|
||||||
|
fullDescription: "Сервис собирает тендерные данные, строит выжимку рисков и помогает подготовить пакет участия.",
|
||||||
|
url: "https://tender.handhdc.ru",
|
||||||
|
launchUrl: "https://tender.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#FFD166",
|
||||||
|
fallbackGradient: "linear-gradient(135deg, rgba(255, 209, 102, 0.84), rgba(74, 53, 19, 0.92) 42%, #0B0D10 86%)",
|
||||||
|
status: "active",
|
||||||
|
order: 40,
|
||||||
|
authentikApplicationSlug: "tender-agent",
|
||||||
|
authentikGroupName: "service-tender-agent",
|
||||||
|
createdAt: "2026-04-03T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_digital_twin",
|
||||||
|
slug: "digital-twin",
|
||||||
|
title: "Digital Twin",
|
||||||
|
subtitle: "3D и пространственные данные",
|
||||||
|
description: "Просмотр цифровых двойников, карт и объектных сцен.",
|
||||||
|
fullDescription: "Витрина геометрии, объектов, слоёв и статусов инфраструктуры.",
|
||||||
|
url: "https://twin.handhdc.ru",
|
||||||
|
launchUrl: "https://twin.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#76E4F7",
|
||||||
|
fallbackGradient: "linear-gradient(140deg, rgba(118, 228, 247, 0.82), rgba(23, 69, 87, 0.92) 47%, #080B0F 86%)",
|
||||||
|
status: "active",
|
||||||
|
order: 50,
|
||||||
|
authentikApplicationSlug: "digital-twin",
|
||||||
|
authentikGroupName: "service-digital-twin",
|
||||||
|
createdAt: "2026-04-05T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_dm",
|
||||||
|
slug: "digital-modules",
|
||||||
|
title: "Digital Modules",
|
||||||
|
subtitle: "Будущие модули",
|
||||||
|
description: "Скрытый каталог модулей для root-admin preview.",
|
||||||
|
fullDescription: "Площадка для будущих цифровых модулей NODE.DC.",
|
||||||
|
url: "https://dm.handhdc.ru",
|
||||||
|
launchUrl: "https://dm.handhdc.ru/sso/launch",
|
||||||
|
accentColor: "#FF9AC2",
|
||||||
|
fallbackGradient: "linear-gradient(135deg, rgba(255, 154, 194, 0.78), rgba(76, 41, 64, 0.9) 44%, #090B0F 86%)",
|
||||||
|
status: "hidden",
|
||||||
|
order: 60,
|
||||||
|
authentikApplicationSlug: "digital-modules",
|
||||||
|
authentikGroupName: "service-digital-modules",
|
||||||
|
createdAt: "2026-04-10T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service_internal",
|
||||||
|
slug: "internal-tools",
|
||||||
|
title: "Internal Tools",
|
||||||
|
subtitle: "Внутренний контур",
|
||||||
|
description: "Отключённый сервис для проверки диагностики root-admin.",
|
||||||
|
fullDescription: "Не показывается обычным пользователям, виден root-admin в каталоге.",
|
||||||
|
url: "https://internal.handhdc.ru",
|
||||||
|
launchUrl: null,
|
||||||
|
accentColor: "#F97373",
|
||||||
|
fallbackGradient: "linear-gradient(135deg, rgba(249, 115, 115, 0.78), rgba(73, 32, 32, 0.92) 43%, #090B0F 86%)",
|
||||||
|
status: "disabled",
|
||||||
|
order: 70,
|
||||||
|
authentikApplicationSlug: "internal-tools",
|
||||||
|
authentikGroupName: "service-internal-tools",
|
||||||
|
createdAt: "2026-04-12T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockGrants: ServiceGrant[] = [
|
||||||
|
grant("grant_romashka_task", "service_task_manager", "client", "client_romashka", "member"),
|
||||||
|
grant("grant_romashka_nodedc_leads", "service_nodedc", "group", "group_romashka_leads", "admin"),
|
||||||
|
grant("grant_romashka_1c_accounting", "service_1c", "group", "group_romashka_accounting", "member"),
|
||||||
|
grant("grant_romashka_tender_ops", "service_tender", "group", "group_romashka_ops", "viewer"),
|
||||||
|
grant("grant_romashka_twin_vasya", "service_digital_twin", "user", "user_vasya", "viewer"),
|
||||||
|
grant("grant_roga_task", "service_task_manager", "client", "client_roga_kopyta", "member"),
|
||||||
|
grant("grant_roga_nodedc", "service_nodedc", "client", "client_roga_kopyta", "viewer"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockExceptions: ServiceAccessException[] = [
|
||||||
|
{
|
||||||
|
id: "exception_lena_task_deny",
|
||||||
|
serviceId: "service_task_manager",
|
||||||
|
userId: "user_lena",
|
||||||
|
type: "deny",
|
||||||
|
reason: "Индивидуально отключён Task Manager на период ревизии доступа.",
|
||||||
|
createdAt: "2026-04-28T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockInvites: Invite[] = [
|
||||||
|
{
|
||||||
|
id: "invite_romashka_analyst",
|
||||||
|
clientId: "client_romashka",
|
||||||
|
email: "analyst@romashka.ru",
|
||||||
|
role: "member",
|
||||||
|
invitedByUserId: "user_ivan",
|
||||||
|
token: "romashka-analyst-demo",
|
||||||
|
expiresAt: "2026-05-15T12:00:00Z",
|
||||||
|
status: "sent",
|
||||||
|
createdAt: "2026-04-30T12:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invite_roga_admin",
|
||||||
|
clientId: "client_roga_kopyta",
|
||||||
|
email: "ops@example.ru",
|
||||||
|
role: "client_admin",
|
||||||
|
invitedByUserId: "user_maria",
|
||||||
|
token: "roga-admin-demo",
|
||||||
|
expiresAt: "2026-05-18T12:00:00Z",
|
||||||
|
status: "created",
|
||||||
|
createdAt: "2026-04-30T14:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockSyncStatuses: SyncStatus[] = [
|
||||||
|
sync("sync_romashka_auth", "client_romashka", "ООО Ромашка", "client", "authentik", "synced"),
|
||||||
|
sync("sync_task_auth", "service_task_manager", "Task Manager", "service", "authentik", "synced"),
|
||||||
|
sync(
|
||||||
|
"sync_lena_task",
|
||||||
|
"exception_lena_task_deny",
|
||||||
|
"Deny: Лена / Task Manager",
|
||||||
|
"grant",
|
||||||
|
"task_manager",
|
||||||
|
"pending",
|
||||||
|
null
|
||||||
|
),
|
||||||
|
sync(
|
||||||
|
"sync_roga_nodedc",
|
||||||
|
"client_roga_kopyta",
|
||||||
|
"ООО Рога и Копыта",
|
||||||
|
"client",
|
||||||
|
"nodedc",
|
||||||
|
"error",
|
||||||
|
"OIDC binding ещё не создан для demo-клиента."
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockAuditEvents: AuditEvent[] = [
|
||||||
|
audit("audit_1", "2026-05-01T08:40:00Z", "user_root", "Root Admin", "Создан сервис", "service", "Digital Modules", "success", null),
|
||||||
|
audit("audit_2", "2026-05-01T08:20:00Z", "user_ivan", "Иван Петров", "Создан invite", "invite", "analyst@romashka.ru", "success", "Срок действия до 15.05.2026"),
|
||||||
|
audit("audit_3", "2026-04-30T17:10:00Z", "user_root", "Root Admin", "Создано deny-исключение", "access", "Лена / Task Manager", "warning", "Индивидуальное правило перекрыло client grant."),
|
||||||
|
audit("audit_4", "2026-04-30T16:00:00Z", "user_root", "Root Admin", "Ошибка синхронизации", "sync", "ООО Рога и Копыта / NodeDC", "error", "Нет application binding."),
|
||||||
|
];
|
||||||
|
|
||||||
|
function membership(
|
||||||
|
id: string,
|
||||||
|
clientId: string,
|
||||||
|
userId: string,
|
||||||
|
role: ClientMembership["role"],
|
||||||
|
status: ClientMembership["status"] = "active"
|
||||||
|
): ClientMembership {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
clientId,
|
||||||
|
userId,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(id: string, clientId: string, name: string, description: string, memberIds: string[]): ClientGroup {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
clientId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
memberIds,
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function grant(
|
||||||
|
id: string,
|
||||||
|
serviceId: string,
|
||||||
|
targetType: ServiceGrant["targetType"],
|
||||||
|
targetId: string,
|
||||||
|
appRole: ServiceGrant["appRole"]
|
||||||
|
): ServiceGrant {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
serviceId,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
appRole,
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2026-04-01T10:00:00Z",
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(
|
||||||
|
id: string,
|
||||||
|
objectId: string,
|
||||||
|
objectName: string,
|
||||||
|
objectType: SyncStatus["objectType"],
|
||||||
|
target: SyncStatus["target"],
|
||||||
|
state: SyncStatus["state"],
|
||||||
|
error: string | null = null
|
||||||
|
): SyncStatus {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
objectId,
|
||||||
|
objectName,
|
||||||
|
objectType,
|
||||||
|
target,
|
||||||
|
state,
|
||||||
|
lastSyncAt: state === "pending" ? null : "2026-05-01T08:00:00Z",
|
||||||
|
error,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function audit(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
actorUserId: string,
|
||||||
|
actorName: string,
|
||||||
|
action: string,
|
||||||
|
objectType: string,
|
||||||
|
objectName: string,
|
||||||
|
result: AuditEvent["result"],
|
||||||
|
details: string | null,
|
||||||
|
clientId: string | null = "client_romashka"
|
||||||
|
): AuditEvent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
actorUserId,
|
||||||
|
actorName,
|
||||||
|
action,
|
||||||
|
objectType,
|
||||||
|
objectName,
|
||||||
|
clientId,
|
||||||
|
result,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type { LauncherData } from "./mockApi";
|
||||||
|
|
||||||
|
export interface StoredFileResponse {
|
||||||
|
ok: true;
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
originalFileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadStorageFile(file: File): Promise<StoredFileResponse> {
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
const response = await fetch("/api/storage/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type || "application/octet-stream",
|
||||||
|
dataUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response);
|
||||||
|
throw new Error(message || "Не удалось сохранить файл в storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<StoredFileResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
return (await response.json()) as LauncherData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistLauncherData(data: LauncherData): Promise<void> {
|
||||||
|
const response = await fetch("/api/storage/data", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response);
|
||||||
|
throw new Error(message || "Не удалось сохранить состояние витрины");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => resolve(String(reader.result)));
|
||||||
|
reader.addEventListener("error", () => reject(reader.error ?? new Error("Не удалось прочитать файл")));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
return payload.error;
|
||||||
|
} catch {
|
||||||
|
return response.statusText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function cn(...values: Array<string | false | null | undefined>): string {
|
||||||
|
return values.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
export function formatDate(value?: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { ClientMembershipRole, ClientMembershipStatus, LauncherGlobalRole } from "../../entities/user/types";
|
||||||
|
|
||||||
|
export interface LauncherPermissions {
|
||||||
|
canOpenAdmin: boolean;
|
||||||
|
canManageClients: boolean;
|
||||||
|
canManageOwnClient: boolean;
|
||||||
|
canManageServiceCatalog: boolean;
|
||||||
|
canInviteUsers: boolean;
|
||||||
|
canManageAccess: boolean;
|
||||||
|
canViewSync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLauncherRole(input: {
|
||||||
|
isRoot?: boolean;
|
||||||
|
membershipRole?: ClientMembershipRole;
|
||||||
|
}): LauncherGlobalRole {
|
||||||
|
if (input.isRoot) return "root_admin";
|
||||||
|
return input.membershipRole ?? "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePermissions(input: {
|
||||||
|
launcherRole: LauncherGlobalRole;
|
||||||
|
membershipStatus?: ClientMembershipStatus;
|
||||||
|
}): LauncherPermissions {
|
||||||
|
const active = input.membershipStatus !== "disabled";
|
||||||
|
const isRoot = input.launcherRole === "root_admin";
|
||||||
|
const isSupport = input.launcherRole === "support_admin";
|
||||||
|
const isClientAdmin = input.launcherRole === "client_owner" || input.launcherRole === "client_admin";
|
||||||
|
|
||||||
|
return {
|
||||||
|
canOpenAdmin: active && (isRoot || isSupport || isClientAdmin),
|
||||||
|
canManageClients: active && isRoot,
|
||||||
|
canManageOwnClient: active && (isRoot || isClientAdmin),
|
||||||
|
canManageServiceCatalog: active && isRoot,
|
||||||
|
canInviteUsers: active && (isRoot || isClientAdmin),
|
||||||
|
canManageAccess: active && (isRoot || isClientAdmin),
|
||||||
|
canViewSync: active && (isRoot || isSupport || isClientAdmin),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClientAdminRole(role: LauncherGlobalRole): boolean {
|
||||||
|
return role === "client_owner" || role === "client_admin";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = "secondary", icon, className, children, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button className={cn("button", `button--${variant}`, className)} {...props}>
|
||||||
|
{icon}
|
||||||
|
{children ? <span>{children}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({ label, className, children, ...props }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button className={cn("icon-button", className)} aria-label={label} title={label} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
interface GlassProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: "default" | "strong" | "soft";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassSurface({ children, className, tone = "default", ...props }: GlassProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("glass-surface", `glass-surface--${tone}`, className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({ children, className, tone = "default", ...props }: GlassProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("glass-card", `glass-card--${tone}`, className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { GlassSurface } from "./Glass";
|
||||||
|
|
||||||
|
export function PortalDropdown({
|
||||||
|
open,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
if (!open || typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<GlassSurface className="portal-dropdown" style={style}>
|
||||||
|
{children}
|
||||||
|
</GlassSurface>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { ClientStatus } from "../../entities/client/types";
|
||||||
|
import type { ServiceStatus } from "../../entities/service/types";
|
||||||
|
import type { SyncState } from "../../entities/sync/types";
|
||||||
|
import type { LauncherUserStatus } from "../../entities/user/types";
|
||||||
|
import { cn } from "../lib/cn";
|
||||||
|
|
||||||
|
type BadgeTone = "green" | "yellow" | "red" | "violet" | "muted";
|
||||||
|
|
||||||
|
const serviceLabels: Record<ServiceStatus, string> = {
|
||||||
|
active: "Активен",
|
||||||
|
maintenance: "Техработы",
|
||||||
|
hidden: "Скрыт",
|
||||||
|
disabled: "Отключён",
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientLabels: Record<ClientStatus, string> = {
|
||||||
|
active: "Активен",
|
||||||
|
demo: "Demo",
|
||||||
|
suspended: "Приостановлен",
|
||||||
|
expired: "Истёк",
|
||||||
|
};
|
||||||
|
|
||||||
|
const userLabels: Record<LauncherUserStatus, string> = {
|
||||||
|
active: "Активен",
|
||||||
|
invited: "Приглашён",
|
||||||
|
blocked: "Заблокирован",
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncLabels: Record<SyncState, string> = {
|
||||||
|
synced: "Синхронизировано",
|
||||||
|
pending: "В очереди",
|
||||||
|
error: "Ошибка",
|
||||||
|
disabled: "Отключено",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
label,
|
||||||
|
tone = "muted",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <span className={cn("status-badge", `status-badge--${tone}`, className)}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceStatusBadge({ status }: { status: ServiceStatus }) {
|
||||||
|
return <StatusBadge label={serviceLabels[status]} tone={statusTone(status)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientStatusBadge({ status }: { status: ClientStatus }) {
|
||||||
|
return <StatusBadge label={clientLabels[status]} tone={clientTone(status)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserStatusBadge({ status }: { status: LauncherUserStatus }) {
|
||||||
|
return <StatusBadge label={userLabels[status]} tone={status === "active" ? "green" : status === "invited" ? "yellow" : "red"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncStatusBadge({ state }: { state: SyncState }) {
|
||||||
|
return <StatusBadge label={syncLabels[state]} tone={syncTone(state)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: ServiceStatus): BadgeTone {
|
||||||
|
if (status === "active") return "green";
|
||||||
|
if (status === "maintenance") return "yellow";
|
||||||
|
if (status === "hidden") return "violet";
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientTone(status: ClientStatus): BadgeTone {
|
||||||
|
if (status === "active") return "green";
|
||||||
|
if (status === "demo") return "yellow";
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTone(state: SyncState): BadgeTone {
|
||||||
|
if (state === "synced") return "green";
|
||||||
|
if (state === "pending") return "yellow";
|
||||||
|
if (state === "error") return "red";
|
||||||
|
return "muted";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Activity, Bot, Boxes, ChartNoAxesColumnIncreasing, KeyRound, Network, Sparkles } from "lucide-react";
|
||||||
|
import type { LauncherServiceView } from "../../entities/service/types";
|
||||||
|
import { cn } from "../../shared/lib/cn";
|
||||||
|
import { ServiceStatusBadge } from "../../shared/ui/StatusBadge";
|
||||||
|
|
||||||
|
export function ServiceRail({
|
||||||
|
services,
|
||||||
|
selectedServiceId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
services: LauncherServiceView[];
|
||||||
|
selectedServiceId?: string;
|
||||||
|
onSelect: (serviceId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="service-rail" aria-label="Доступные сервисы">
|
||||||
|
{services.map((service) => (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
className={cn("service-tile", selectedServiceId === service.id && "service-tile--active")}
|
||||||
|
onClick={() => onSelect(service.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="service-tile__media" style={{ "--tile-accent": service.accentColor ?? "#B5FF5A" } as React.CSSProperties}>
|
||||||
|
<ServiceIcon slug={service.slug} />
|
||||||
|
</span>
|
||||||
|
<span className="service-tile__content">
|
||||||
|
<strong>{service.title}</strong>
|
||||||
|
<small>{service.subtitle}</small>
|
||||||
|
</span>
|
||||||
|
<ServiceStatusBadge status={service.status} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceIcon({ slug }: { slug: string }) {
|
||||||
|
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
|
||||||
|
if (slug.includes("1c")) return <Boxes size={18} />;
|
||||||
|
if (slug.includes("tender")) return <KeyRound size={18} />;
|
||||||
|
if (slug.includes("twin")) return <Activity size={18} />;
|
||||||
|
if (slug.includes("digital-modules")) return <Sparkles size={18} />;
|
||||||
|
if (slug.includes("internal")) return <Network size={18} />;
|
||||||
|
return <Bot size={18} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Bot,
|
||||||
|
Boxes,
|
||||||
|
ChartNoAxesColumnIncreasing,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
LockKeyhole,
|
||||||
|
Network,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LauncherServiceView } from "../../entities/service/types";
|
||||||
|
import { Button } from "../../shared/ui/Button";
|
||||||
|
import { ServiceStatusBadge, StatusBadge } from "../../shared/ui/StatusBadge";
|
||||||
|
|
||||||
|
const DEFAULT_STAGE_MEDIA = "/storage/default.gif";
|
||||||
|
|
||||||
|
export function ServiceStage({
|
||||||
|
service,
|
||||||
|
hasServices,
|
||||||
|
onLaunch,
|
||||||
|
}: {
|
||||||
|
service?: LauncherServiceView;
|
||||||
|
hasServices: boolean;
|
||||||
|
onLaunch: (service: LauncherServiceView) => void;
|
||||||
|
}) {
|
||||||
|
if (!hasServices) {
|
||||||
|
return (
|
||||||
|
<section className="service-stage service-stage--empty">
|
||||||
|
<div className="empty-stage-card">
|
||||||
|
<Network size={28} />
|
||||||
|
<h1>Нет доступных сервисов</h1>
|
||||||
|
<p>Для активного клиента не найдено доступных приложений. Проверьте гранты или статус клиента.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
"--service-accent": service?.accentColor ?? "#C3FF66",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
const disabledReason = service
|
||||||
|
? service.status === "maintenance"
|
||||||
|
? "Сервис временно недоступен"
|
||||||
|
: service.userAccess === "denied"
|
||||||
|
? "Доступ не выдан"
|
||||||
|
: service.effectiveAccess.openEnabled
|
||||||
|
? null
|
||||||
|
: "Открытие заблокировано"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="service-stage" style={style}>
|
||||||
|
<div className="stage-video-shell">
|
||||||
|
<div className="stage-video-stream" aria-hidden="true">
|
||||||
|
{service?.media.ambientVideo ? (
|
||||||
|
<StageMedia className="stage-video-gif" src={service.media.ambientVideo} kind={service.media.ambientKind} />
|
||||||
|
) : (
|
||||||
|
<img className="stage-video-gif" src={DEFAULT_STAGE_MEDIA} alt="" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-video-topline">
|
||||||
|
<button className="stage-round-button" type="button" aria-label="Назад">
|
||||||
|
<ChevronLeft size={17} />
|
||||||
|
</button>
|
||||||
|
<span>{service?.title ?? "Витрина NODE.DC"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-side-controls" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{service ? (
|
||||||
|
<div className="stage-service-overlay">
|
||||||
|
<article className="stage-image-card">
|
||||||
|
<div className="stage-image-card__visual">
|
||||||
|
{service.media.coverImage || service.media.thumbnail ? (
|
||||||
|
<StageMedia
|
||||||
|
className="stage-image-card__image"
|
||||||
|
src={service.media.coverImage ?? service.media.thumbnail ?? ""}
|
||||||
|
kind={service.media.coverKind}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stage-image-card__figure" />
|
||||||
|
<div className="stage-image-card__glow" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<aside className="stage-description-card">
|
||||||
|
<div className="stage-card-label">
|
||||||
|
<ServiceIcon slug={service.slug} />
|
||||||
|
<span>Description</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-description-card__copy">
|
||||||
|
<h1>{service.title}</h1>
|
||||||
|
<RichDescription text={service.fullDescription ?? service.description} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-description-card__chips">
|
||||||
|
<ServiceStatusBadge status={service.status} />
|
||||||
|
<StatusBadge
|
||||||
|
label={service.userAccess === "allowed" ? `Доступ: ${service.appRole ?? "member"}` : "Нет доступа"}
|
||||||
|
tone={service.userAccess === "allowed" ? "green" : "red"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-description-card__reason">
|
||||||
|
<span>Почему видно</span>
|
||||||
|
<strong>{service.effectiveAccess.reason}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stage-description-card__actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
icon={
|
||||||
|
service.status === "maintenance" ? (
|
||||||
|
<Wrench size={16} />
|
||||||
|
) : service.userAccess === "denied" ? (
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
) : (
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => onLaunch(service)}
|
||||||
|
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
|
||||||
|
>
|
||||||
|
{disabledReason ?? "Открыть"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
onClick={() => onLaunch(service)}
|
||||||
|
disabled={!service.effectiveAccess.openEnabled || !service.openUrl}
|
||||||
|
>
|
||||||
|
Перейти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="stage-video-controls" aria-hidden="true">
|
||||||
|
<button type="button" tabIndex={-1}>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" tabIndex={-1}>
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceIcon({ slug }: { slug: string }) {
|
||||||
|
if (slug.includes("task")) return <ChartNoAxesColumnIncreasing size={18} />;
|
||||||
|
if (slug.includes("1c")) return <Boxes size={18} />;
|
||||||
|
if (slug.includes("tender")) return <KeyRound size={18} />;
|
||||||
|
if (slug.includes("twin")) return <Activity size={18} />;
|
||||||
|
return <Bot size={18} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
|
||||||
|
if (kind === "video" || isVideoSource(src)) {
|
||||||
|
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <img className={className} src={src} alt="" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichDescription({ text }: { text: string }) {
|
||||||
|
const blocks = text
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!blocks.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stage-rich-description">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
const lines = block.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||||
|
const isList = lines.length > 0 && lines.every((line) => /^[-*]\s+/.test(line));
|
||||||
|
|
||||||
|
if (isList) {
|
||||||
|
return (
|
||||||
|
<ul key={`list-${index}`}>
|
||||||
|
{lines.map((line, lineIndex) => (
|
||||||
|
<li key={lineIndex}>{renderInlineRichText(line.replace(/^[-*]\s+/, ""))}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p key={`p-${index}`}>{renderInlineRichText(lines.join("\n"))}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineRichText(text: string): ReactNode[] {
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
const pattern = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*]+\*|_[^_]+_|\n)/g;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text))) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = match[0];
|
||||||
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
|
if (token === "\n") {
|
||||||
|
parts.push(<br key={key} />);
|
||||||
|
} else if (token.startsWith("**") || token.startsWith("__")) {
|
||||||
|
parts.push(<strong key={key}>{token.slice(2, -2)}</strong>);
|
||||||
|
} else if (token.startsWith("`")) {
|
||||||
|
parts.push(<code key={key}>{token.slice(1, -1)}</code>);
|
||||||
|
} else {
|
||||||
|
parts.push(<em key={key}>{token.slice(1, -1)}</em>);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = pattern.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoSource(src: string) {
|
||||||
|
return /\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i.test(src);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { Inbox } from "lucide-react";
|
||||||
|
import type { Client } from "../../entities/client/types";
|
||||||
|
import type { MeResponse, ProfileOption } from "../../shared/api/mockApi";
|
||||||
|
import { initials } from "../../shared/lib/format";
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
me,
|
||||||
|
clients,
|
||||||
|
profileOptions,
|
||||||
|
activeProfileId,
|
||||||
|
activeClientId,
|
||||||
|
adminOpen,
|
||||||
|
onProfileChange,
|
||||||
|
onClientChange,
|
||||||
|
onOpenAdmin,
|
||||||
|
onOpenShowcase,
|
||||||
|
}: {
|
||||||
|
me: MeResponse;
|
||||||
|
clients: Client[];
|
||||||
|
profileOptions: ProfileOption[];
|
||||||
|
activeProfileId: string;
|
||||||
|
activeClientId: string;
|
||||||
|
adminOpen: boolean;
|
||||||
|
onProfileChange: (userId: string) => void;
|
||||||
|
onClientChange: (clientId: string) => void;
|
||||||
|
onOpenAdmin: () => void;
|
||||||
|
onOpenShowcase: () => void;
|
||||||
|
}) {
|
||||||
|
const availableClientIds = new Set(me.memberships.map((membership) => membership.clientId));
|
||||||
|
const availableClients = clients.filter((client) => availableClientIds.has(client.id));
|
||||||
|
const activeClient = availableClients.find((client) => client.id === activeClientId);
|
||||||
|
const activeProfile = profileOptions.find((profile) => profile.userId === activeProfileId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="nodedc-expanded-toolbar-shell">
|
||||||
|
<div className="nodedc-expanded-toolbar">
|
||||||
|
<div className="nodedc-expanded-toolbar-top">
|
||||||
|
<div className="nodedc-expanded-toolbar-left">
|
||||||
|
<a href="/" aria-label="NODE.DC">
|
||||||
|
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-expanded-toolbar-center">
|
||||||
|
<label className="nodedc-expanded-workspace-button" title={activeClient?.name ?? "Клиент"}>
|
||||||
|
<img src="/nodedc-mark.svg" alt="" className="nodedc-expanded-workspace-mark" />
|
||||||
|
<select value={activeClientId} onChange={(event) => onClientChange(event.target.value)} aria-label="Выбрать клиента">
|
||||||
|
{availableClients.map((client) => (
|
||||||
|
<option key={client.id} value={client.id}>
|
||||||
|
{client.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<nav className="nodedc-expanded-nav-group" aria-label="Навигация лаунчера">
|
||||||
|
<button className="nodedc-expanded-nav-button" type="button" data-active={!adminOpen} onClick={onOpenShowcase}>
|
||||||
|
<span>Витрина</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="nodedc-expanded-nav-button nodedc-expanded-select-button" data-active="false">
|
||||||
|
<span>{activeProfile?.label ?? me.user.name}</span>
|
||||||
|
<select value={activeProfileId} onChange={(event) => onProfileChange(event.target.value)} aria-label="Выбрать профиль">
|
||||||
|
{profileOptions.map((profile) => (
|
||||||
|
<option key={profile.userId} value={profile.userId}>
|
||||||
|
{profile.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{me.permissions.canOpenAdmin ? (
|
||||||
|
<button className="nodedc-expanded-nav-button" type="button" data-active={adminOpen} onClick={onOpenAdmin}>
|
||||||
|
<span>Администрирование</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nodedc-expanded-toolbar-right">
|
||||||
|
<div className="nodedc-expanded-user-group" title={`${me.user.name} · ${me.user.email}`}>
|
||||||
|
<button className="nodedc-expanded-nav-button" type="button" data-active="false">
|
||||||
|
<span>Профиль</span>
|
||||||
|
</button>
|
||||||
|
<button className="nodedc-toolbar-icon-button nodedc-expanded-notification-button" type="button" data-active="false" aria-label="Уведомления">
|
||||||
|
<span className="nodedc-toolbar-icon-active-dot">
|
||||||
|
<Inbox size={20} strokeWidth={1.7} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button className="nodedc-expanded-user-avatar-button" type="button" aria-label="Профиль пользователя">
|
||||||
|
<span className="nodedc-expanded-user-avatar">{initials(me.user.name)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
import { dirname, extname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const maxStorageJsonBodyBytes = 260 * 1024 * 1024;
|
||||||
|
|
||||||
|
function localStorageApiPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "nodedc-local-storage-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
const pathname = req.url?.split("?")[0];
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/storage/upload") {
|
||||||
|
try {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
const result = await saveUploadedFile(payload);
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/storage/data") {
|
||||||
|
try {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
await saveLauncherData(payload);
|
||||||
|
sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
const pathname = req.url?.split("?")[0];
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/storage/upload") {
|
||||||
|
try {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
const result = await saveUploadedFile(payload);
|
||||||
|
sendJson(res, 200, result);
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: error instanceof Error ? error.message : "Upload failed" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && pathname === "/api/storage/data") {
|
||||||
|
try {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
await saveLauncherData(payload);
|
||||||
|
sendJson(res, 200, { ok: true, url: "/storage/launcher-data.json" });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { error: error instanceof Error ? error.message : "Data save failed" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(req: NodeJS.ReadableStream) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
totalBytes += buffer.byteLength;
|
||||||
|
|
||||||
|
if (totalBytes > maxStorageJsonBodyBytes) {
|
||||||
|
throw new Error("Файл слишком большой для локального mock-storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUploadedFile(payload: unknown) {
|
||||||
|
if (!isUploadPayload(payload)) {
|
||||||
|
throw new Error("Некорректный payload загрузки");
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s.exec(payload.dataUrl);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Файл должен прийти data-url с base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = payload.mimeType || match[1] || "application/octet-stream";
|
||||||
|
const storedName = buildStoredFileName(payload.fileName, mimeType);
|
||||||
|
const fileBuffer = Buffer.from(match[2], "base64");
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
getWritableStorageRoots().map(async (storageRoot) => {
|
||||||
|
const uploadDir = join(storageRoot, "uploads");
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
await writeFile(join(uploadDir, storedName), fileBuffer);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
url: `/storage/uploads/${storedName}`,
|
||||||
|
fileName: storedName,
|
||||||
|
originalFileName: payload.fileName,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLauncherData(payload: unknown) {
|
||||||
|
await Promise.all(
|
||||||
|
getWritableStorageRoots().map(async (storageRoot) => {
|
||||||
|
await mkdir(storageRoot, { recursive: true });
|
||||||
|
await writeFile(join(storageRoot, "launcher-data.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWritableStorageRoots() {
|
||||||
|
const roots = [join(projectRoot, "public", "storage")];
|
||||||
|
const distRoot = join(projectRoot, "dist");
|
||||||
|
|
||||||
|
if (existsSync(distRoot)) {
|
||||||
|
roots.push(join(distRoot, "storage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredFileName(fileName: string, mimeType: string) {
|
||||||
|
const extension = extname(fileName) || extensionFromMimeType(mimeType);
|
||||||
|
const rawBase = fileName.slice(0, extension ? -extension.length : undefined);
|
||||||
|
const safeBase =
|
||||||
|
rawBase
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w.-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80) || "upload";
|
||||||
|
|
||||||
|
return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBase}${extension.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFromMimeType(mimeType: string) {
|
||||||
|
if (mimeType === "image/jpeg") return ".jpg";
|
||||||
|
if (mimeType === "image/png") return ".png";
|
||||||
|
if (mimeType === "image/gif") return ".gif";
|
||||||
|
if (mimeType === "image/webp") return ".webp";
|
||||||
|
if (mimeType === "video/mp4") return ".mp4";
|
||||||
|
if (mimeType === "video/webm") return ".webm";
|
||||||
|
if (mimeType === "video/quicktime") return ".mov";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, statusCode: number, payload: unknown) {
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUploadPayload(payload: unknown): payload is { fileName: string; mimeType: string; dataUrl: string } {
|
||||||
|
if (!payload || typeof payload !== "object") return false;
|
||||||
|
|
||||||
|
const candidate = payload as { fileName?: unknown; mimeType?: unknown; dataUrl?: unknown };
|
||||||
|
|
||||||
|
return typeof candidate.fileName === "string" && typeof candidate.mimeType === "string" && typeof candidate.dataUrl === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), localStorageApiPlugin()],
|
||||||
|
});
|
||||||