feat: add launcher frontend MVP

This commit is contained in:
DCCONSTRUCTIONS 2026-05-01 18:39:59 +03:00
parent 63d21a7a57
commit e8c6e76885
46 changed files with 9497 additions and 0 deletions

9
.gitignore vendored
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.vite/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.tsbuildinfo

224
design.md Normal file
View File

@ -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-правами внутри подключённых сервисов.

BIN
doc/base/1.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

424
doc/base/HDESIGN-CODE.md Normal file
View File

@ -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>
```

12
index.html Normal file
View File

@ -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>

2298
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

1
public/nodedc-logo.svg Normal file
View File

@ -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

4
public/nodedc-mark.svg Normal file
View File

@ -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

BIN
public/storage/default.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

View File

@ -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."
}
]
}

View File

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

272
src/app/LauncherApp.tsx Normal file
View File

@ -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>
);
}

View File

@ -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",
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

10
src/main.tsx Normal file
View File

@ -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>
);

344
src/shared/api/mockApi.ts Normal file
View File

@ -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];
}

448
src/shared/api/mockData.ts Normal file
View File

@ -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,
};
}

View File

@ -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;
}
}

3
src/shared/lib/cn.ts Normal file
View File

@ -0,0 +1,3 @@
export function cn(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(" ");
}

28
src/shared/lib/format.ts Normal file
View File

@ -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("");
}

View File

@ -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";
}

30
src/shared/ui/Button.tsx Normal file
View File

@ -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>
);
}

23
src/shared/ui/Glass.tsx Normal file
View File

@ -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>
);
}

View File

@ -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
);
}

View File

@ -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";
}

2209
src/styles/globals.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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} />;
}

View File

@ -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);
}

View File

@ -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>
);
}

21
tsconfig.app.json Normal file
View File

@ -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"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

14
tsconfig.node.json Normal file
View File

@ -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"]
}

187
vite.config.ts Normal file
View File

@ -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()],
});