Initial DC CMS extraction

This commit is contained in:
DCCONSTRUCTIONS 2026-07-06 00:33:27 +03:00
commit 43a96c9f7f
23 changed files with 13844 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
.DS_Store
node_modules
npm-debug.log*
__pycache__/
*.pyc
.env
*.local
infra/.env
.cms-smoke.png

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.DS_Store
node_modules/
npm-debug.log*
__pycache__/
*.pyc
.env
*.local
infra/.env
.cms-smoke.png

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY admin ./admin
COPY projects ./projects
COPY server ./server
ENV HOST=0.0.0.0
ENV PORT=8090
EXPOSE 8090
CMD ["npm", "run", "admin"]

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# DC CMS
Отдельный сервис администрирования сайтов DCTOUCH / NODE.DC.
## Первый подключенный проект
`projects/nodedc.json` подключает существующий сайт:
- `siteRoot`: локальный путь к репозиторию сайта.
- `previewUrl`: локальный адрес предпросмотра.
- `publicUrl`: публичный домен сайта.
- `deploy`: настройки будущей публикации на хостинг.
Пароль хостинга не хранится в JSON. В конфиге указывается имя переменной окружения, например `MCHOST_PASSWORD`.
## Локальный запуск
```bash
PORT=8210 npm run admin
```
Админка будет доступна на `http://127.0.0.1:8210/admin/`, а подключенный сайт на `http://127.0.0.1:8210/`.
## Запуск с Authentik
Полная локальная связка лежит в `infra/`: CMS, отдельный Authentik, Postgres и Caddy reverse proxy. Секреты хранятся только в локальном `infra/.env`; файл игнорируется git и Docker build context.
Для локальных доменов нужны host aliases:
```bash
127.0.0.1 cms.local.nodedc cms-auth.local.nodedc cms-auth-admin.local.nodedc
```
Старт:
```bash
docker compose --env-file infra/.env -f infra/docker-compose.yml up -d --build
infra/scripts/bootstrap-authentik.sh
```
Адреса:
- CMS: `http://cms.local.nodedc:8210/admin/`
- Authentik login: `http://cms-auth.local.nodedc:8210/`
- Authentik admin: `http://cms-auth-admin.local.nodedc:8210/if/admin/`
Bootstrap создает OIDC-приложение `dc-cms`, группы `dc-cms:*` и стартового администратора из `infra/.env`. После первичного входа временный пароль нужно заменить в Authentik.
## Synology
Для Synology используется отдельный env-шаблон `infra/.env.synology.example`. Рабочий файл должен лежать рядом как `infra/.env.synology` и не должен попадать в artifact.
Публичные host routes:
- `cms.dcserve.ru` -> CMS.
- `auth.dcserve.ru` -> Authentik login/OIDC issuer.
- `auth-admin.dcserve.ru` -> Authentik admin, опционально и лучше не открывать публично без необходимости.
DNS-записи должны вести на внешний IP NAS. В Synology Reverse Proxy:
- `cms.dcserve.ru` -> `http://172.22.0.222:9918`
- `auth.dcserve.ru` -> `http://172.22.0.222:9919`
Оба target-порта ведут в один Caddy контейнер внутри compose-проекта `dc-cms`; Caddy разводит запросы по Host.

2995
admin/admin.css Normal file

File diff suppressed because it is too large Load Diff

5751
admin/admin.js Normal file

File diff suppressed because it is too large Load Diff

351
admin/index.html Normal file
View File

@ -0,0 +1,351 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NODE.DC Admin</title>
<link rel="stylesheet" href="./admin.css" />
</head>
<body>
<div class="admin-backdrop" aria-hidden="true"></div>
<header class="nodedc-expanded-toolbar-shell">
<div class="nodedc-expanded-toolbar">
<div class="nodedc-expanded-toolbar-top">
<div class="nodedc-expanded-toolbar-left">
<a href="/" class="nodedc-expanded-brand-link" target="_blank" aria-label="NODE.DC">
<img src="./nodedc-logo.svg" alt="NODE.DC" class="nodedc-expanded-brand-logo" />
</a>
</div>
<div class="nodedc-expanded-toolbar-center">
<span class="nodedc-expanded-workspace-button" aria-hidden="true">
<img src="../assets/uploads/images/Logo/dclogo_white_alpha-mqus5if2.png" alt="" class="nodedc-expanded-workspace-avatar" />
</span>
<nav class="nodedc-expanded-nav-group" aria-label="Навигация админки">
<button id="admin-mode-content" class="nodedc-expanded-nav-button" type="button" data-active="true">
<span>Администрирование</span>
</button>
<button id="admin-mode-seo" class="nodedc-expanded-nav-button" type="button" data-active="false">
<span>SEO</span>
</button>
<button id="admin-mode-knowledge" class="nodedc-expanded-nav-button" type="button" data-active="false">
<span>База знаний</span>
</button>
</nav>
</div>
<div class="nodedc-expanded-toolbar-right">
<div class="nodedc-expanded-user-group">
<span class="nodedc-toolbar-icon-button" aria-hidden="true"></span>
<span class="nodedc-expanded-profile-trigger">
<span class="nodedc-expanded-nav-button" data-active="false">
<span>Профиль</span>
</span>
<span class="nodedc-expanded-user-avatar-button" aria-hidden="true">
<span class="nodedc-expanded-user-avatar">DC</span>
</span>
</span>
</div>
</div>
</div>
</div>
</header>
<main class="admin-shell">
<aside class="sidebar">
<div class="sidebar-head">
<div>
<div class="section-kicker">NODE.DC</div>
<h1 id="sidebar-title">Администрирование</h1>
</div>
<button id="project-settings" class="admin-panel-close project-settings-button" type="button" aria-label="Настройки проекта" title="Настройки проекта">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z" />
<path d="M19.4 13.5c.06-.48.06-.98 0-1.5l2-1.55-1.9-3.3-2.35.95a8.2 8.2 0 0 0-1.3-.75L15.5 4.8h-3.8l-.35 2.55c-.46.2-.9.45-1.3.75L7.7 7.15l-1.9 3.3L7.8 12a8.5 8.5 0 0 0 0 1.5l-2 1.55 1.9 3.3 2.35-.95c.4.3.84.55 1.3.75l.35 2.55h3.8l.35-2.55c.46-.2.9-.45 1.3-.75l2.35.95 1.9-3.3-2-1.55Z" />
</svg>
</button>
<button id="add-block" class="admin-panel-close add-block-button" type="button" aria-label="Добавить блок">+</button>
<div class="workspace-card admin-panel-client-select">
<div class="workspace-logo admin-panel-client-select__icon">DC</div>
<div>
<div id="workspace-name" class="workspace-name">NODE.DC</div>
<div id="workspace-role" class="workspace-role">Администратор сайта</div>
</div>
</div>
</div>
<div id="regions" class="regions"></div>
</aside>
<section class="editor">
<header class="editor-head">
<div>
<h1 id="editor-heading">Настройки сайта</h1>
<div id="selected-region" class="breadcrumb">NODE.DC / Выберите блок</div>
<h2 id="selected-title">Контентный слой</h2>
</div>
<div class="actions">
<span id="dirty-state" class="dirty-state" data-dirty="false">Синхронизировано</span>
<button id="reload" class="icon-btn" type="button" title="Перезагрузить данные"></button>
<button id="save" class="primary-btn" type="button" title="Записать текущую модель в content/pages/home.json">Сохранить модель</button>
<button id="render" class="ghost-btn" type="button" title="Сохранить модель и пересобрать публичный index.html">Обновить index.html</button>
</div>
</header>
<div id="empty-state" class="empty-state">
<div class="empty-icon">ND</div>
<div>
<h3>Выберите блок слева</h3>
<p>Статика, hero, форма и секции страницы разделены на рабочие группы. Сначала редактируем typed-поля, сырой JSON оставлен как технический fallback.</p>
</div>
</div>
<form id="block-form" class="block-form hidden">
<section class="settings-card block-meta-card">
<div class="card-head">
<div>
<div class="section-kicker">Идентификатор</div>
<h3>Служебные параметры</h3>
</div>
<label class="toggle-row">
<input id="block-enabled" name="enabled" type="checkbox" />
<span>Отображение</span>
</label>
</div>
<div class="field-grid">
<label class="field-control">
<span>ID блока</span>
<input id="block-id" name="id" type="text" autocomplete="off" />
</label>
<label class="field-control">
<span>Название в админке</span>
<input id="block-label" name="adminLabel" type="text" autocomplete="off" />
</label>
<label class="field-control">
<span>Шаблон / тип</span>
<input id="block-template" name="template" type="text" autocomplete="off" readonly />
</label>
<label class="field-control">
<span>Target</span>
<input id="block-anchor" name="target" type="text" autocomplete="off" list="block-target-options" />
<datalist id="block-target-options"></datalist>
</label>
</div>
</section>
<section class="settings-card structure-card">
<div class="card-head structure-head">
<div>
<div class="section-kicker">Порядок блока</div>
<h3>Операции с блоком</h3>
</div>
<div class="button-row icon-action-row">
<button id="move-up" class="icon-action-btn" type="button" title="Поднять блок" aria-label="Поднять блок">
<span aria-hidden="true"></span>
</button>
<button id="move-down" class="icon-action-btn" type="button" title="Опустить блок" aria-label="Опустить блок">
<span aria-hidden="true"></span>
</button>
<button id="duplicate" class="icon-action-btn" type="button" title="Дублировать блок" aria-label="Дублировать блок">
<span aria-hidden="true"></span>
</button>
<button id="copy" class="icon-action-btn operation-extra" type="button" title="Копировать блок" aria-label="Копировать блок">
<span aria-hidden="true"></span>
</button>
<button id="paste-after" class="icon-action-btn operation-extra" type="button" title="Вставить после" aria-label="Вставить после">
<span aria-hidden="true"></span>
</button>
<button id="delete-block" class="icon-action-btn danger-action-btn" type="button" title="Удалить блок" aria-label="Удалить блок">
<svg class="trash-can-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3.75 6.75h16.5" />
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
<path d="M10 10.75v5.5" />
<path d="M14 10.75v5.5" />
</svg>
</button>
</div>
</div>
</section>
<section class="content-panel">
<div class="panel-head">
<div>
<div id="content-panel-kicker" class="section-kicker">Контент</div>
<h3 id="content-panel-title">Редактируемые поля</h3>
</div>
<span id="content-panel-note" class="panel-note">Поля сгруппированы по смысловым подблокам верстки.</span>
</div>
<div id="content-fields" class="content-fields"></div>
</section>
<details class="json-details">
<summary>JSON выбранного блока</summary>
<label class="json-field">
<span>Техническое представление</span>
<textarea id="block-json" spellcheck="false"></textarea>
</label>
</details>
</form>
<div id="knowledge-form" class="knowledge-editor hidden"></div>
<div id="project-settings-form" class="project-settings-editor hidden"></div>
<pre id="status" class="status"></pre>
</section>
</main>
<div id="template-modal" class="template-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
<article class="template-modal">
<header class="template-modal-head">
<div>
<div class="section-kicker">Библиотека блоков</div>
<h2 id="template-modal-title">Добавить блок</h2>
<p>Выберите шаблон. Новый блок появится в конце соответствующего списка.</p>
</div>
<button id="template-modal-close" class="admin-panel-close modal-close-button" type="button" aria-label="Закрыть">×</button>
</header>
<div id="template-list" class="template-list"></div>
<footer class="template-modal-foot">
<button id="template-modal-cancel" class="ghost-btn" type="button">Отмена</button>
</footer>
</article>
</div>
<div id="delete-modal" class="template-modal-layer delete-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-body">
<article class="template-modal delete-modal">
<header class="template-modal-head delete-modal-head">
<div>
<div class="section-kicker">Удаление</div>
<h2 id="delete-modal-title">Подтвердите действие</h2>
<p id="delete-modal-body">Вы действительно подтвердить удаление элемента?</p>
</div>
</header>
<footer class="template-modal-foot delete-modal-foot">
<button id="delete-modal-cancel" class="ghost-btn" type="button">Отмена</button>
<button id="delete-modal-confirm" class="danger-btn delete-confirm-btn" type="button">Да</button>
</footer>
</article>
</div>
<div id="media-modal" class="template-modal-layer media-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="media-modal-title">
<article class="template-modal media-modal">
<header class="template-modal-head media-modal-head">
<div>
<div class="section-kicker">Медиатека</div>
<h2 id="media-modal-title">Выбрать файл</h2>
<p id="media-modal-subtitle">Существующие файлы на сервере и загрузки для текущего поля.</p>
</div>
<div class="media-modal-actions">
<span id="media-site-size" class="media-site-size">Сайт: считаем...</span>
<button id="media-trash-toggle" class="admin-panel-close media-header-action media-trash-toggle" type="button" aria-label="Недавно удалённые" title="Недавно удалённые">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.75 6.75h16.5" />
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
<path d="M10 10.75v5.5" />
<path d="M14 10.75v5.5" />
</svg>
</button>
<button id="media-modal-close" class="admin-panel-close modal-close-button media-header-action" type="button" aria-label="Закрыть">×</button>
</div>
</header>
<div class="media-toolbar">
<label class="media-search">
<span>Поиск</span>
<input id="media-search" type="search" autocomplete="off" placeholder="Имя, папка, путь" />
</label>
<div id="media-kind-filters" class="media-filter-row media-kind-row" aria-label="Тип файла"></div>
<div id="media-root-filters" class="media-filter-row media-root-row" aria-label="Корень файлов"></div>
<button id="media-upload" class="primary-btn media-upload-btn" type="button">+ Загрузить</button>
</div>
<div id="media-drop-zone" class="media-browser media-browser-columns">
<section class="media-main">
<div id="media-breadcrumbs" class="media-breadcrumbs"></div>
<div id="media-list" class="media-columns"></div>
<div id="media-empty" class="media-empty hidden">Папка пуста.</div>
</section>
<aside class="media-preview-panel">
<div id="media-preview" class="media-preview">
<span>FILE</span>
</div>
<div class="media-preview-copy">
<div id="media-preview-name" class="media-preview-name">Выберите файл</div>
<div id="media-preview-path" class="media-preview-path"></div>
<div id="media-preview-meta" class="media-preview-meta"></div>
</div>
<div id="media-preview-usage" class="media-usage-pill" data-status="idle">
<span class="media-usage-dot"></span>
<span>Нет выбора</span>
</div>
<div class="media-preview-actions">
<button id="media-select" class="primary-btn" type="button" disabled>Выбрать</button>
<button id="media-delete" class="danger-btn media-delete-btn" type="button" aria-label="Удалить файл" title="Удалить файл" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.75 6.75h16.5" />
<path d="M8.25 6.75V4.9c0-.64.51-1.15 1.15-1.15h5.2c.64 0 1.15.51 1.15 1.15v1.85" />
<path d="M6.25 6.75l.7 12.15c.06.78.7 1.35 1.47 1.35h7.16c.77 0 1.41-.57 1.47-1.35l.7-12.15" />
<path d="M10 10.75v5.5" />
<path d="M14 10.75v5.5" />
</svg>
</button>
</div>
</aside>
</div>
<footer class="template-modal-foot media-modal-foot">
<button id="media-refresh" class="ghost-btn" type="button">Обновить список</button>
<button id="media-modal-cancel" class="ghost-btn" type="button">Отмена</button>
</footer>
</article>
</div>
<div id="media-context-menu" class="media-context-menu hidden" role="menu"></div>
<div id="media-action-modal" class="template-modal-layer media-action-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="media-action-title">
<article class="template-modal media-action-modal">
<header class="template-modal-head media-action-head">
<div>
<div class="section-kicker">Файл-менеджер</div>
<h2 id="media-action-title">Действие</h2>
<p id="media-action-body">Введите имя.</p>
</div>
</header>
<div class="media-action-body">
<label class="field-control">
<span id="media-action-label">Имя</span>
<input id="media-action-input" type="text" autocomplete="off" />
</label>
</div>
<footer class="template-modal-foot media-action-foot">
<button id="media-action-cancel" class="ghost-btn" type="button">Отмена</button>
<button id="media-action-confirm" class="primary-btn" type="button">Подтвердить</button>
</footer>
</article>
</div>
<div id="knowledge-button-modal" class="template-modal-layer knowledge-button-modal-layer hidden" role="dialog" aria-modal="true" aria-labelledby="knowledge-button-title">
<article class="template-modal knowledge-button-modal">
<header class="template-modal-head knowledge-button-head">
<div>
<div class="section-kicker">База знаний</div>
<h2 id="knowledge-button-title">Кнопка в статье</h2>
<p>Ссылка ведёт на лендинг или внешний адрес. Target открывает нужный блок лендинга.</p>
</div>
<button id="knowledge-button-close" class="admin-panel-close modal-close-button" type="button" aria-label="Закрыть">×</button>
</header>
<div class="knowledge-button-body">
<label class="field-control">
<span>Название</span>
<input id="knowledge-button-name" type="text" autocomplete="off" placeholder="Посмотреть демо" />
</label>
<label class="field-control">
<span>Ссылка</span>
<input id="knowledge-button-link" type="text" autocomplete="off" placeholder="/" />
</label>
<label class="field-control">
<span>Target / блок лендинга</span>
<input id="knowledge-button-target" type="text" autocomplete="off" list="knowledge-button-target-options" placeholder="hub-video" />
<datalist id="knowledge-button-target-options"></datalist>
</label>
<p class="knowledge-button-hint">Можно выбрать target из списка или ввести ID блока лендинга. Если target пустой, ссылка откроет первый экран.</p>
<p id="knowledge-button-error" class="knowledge-button-error" aria-live="polite"></p>
</div>
<footer class="template-modal-foot knowledge-button-foot">
<button id="knowledge-button-cancel" class="ghost-btn" type="button">Отмена</button>
<button id="knowledge-button-confirm" class="primary-btn" type="button">Добавить кнопку</button>
</footer>
</article>
</div>
<script src="./admin.js" type="module"></script>
</body>
</html>

1
admin/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

52
infra/.env.example Normal file
View File

@ -0,0 +1,52 @@
# domains
CMS_DOMAIN=cms.local.nodedc
CMS_AUTH_DOMAIN=cms-auth.local.nodedc
CMS_AUTH_ADMIN_DOMAIN=cms-auth-admin.local.nodedc
CMS_PUBLIC_URL=http://cms.local.nodedc:8210
CMS_AUTH_PUBLIC_URL=http://cms-auth.local.nodedc:8210
CMS_AUTH_ADMIN_PUBLIC_URL=http://cms-auth-admin.local.nodedc:8210
CMS_HTTP_BIND=127.0.0.1:8210
CMS_AUTH_HTTP_BIND=127.0.0.1:8211
CMS_AUTH_ADMIN_HTTP_BIND=127.0.0.1:8212
CMS_EXTERNAL_SCHEME=http
# mounted NODE.DC site for local development
CMS_NODEDC_SITE_PATH=../../NODEDC_SITE
# authentik image
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
AUTHENTIK_TAG=2026.2.2
AUTHENTIK_ERROR_REPORTING__ENABLED=false
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12
# authentik database
PG_DB=authentik
PG_USER=authentik
PG_PASS=replace-with-random-secret
# authentik first-start bootstrap
AUTHENTIK_SECRET_KEY=replace-with-random-secret
AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh.
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
# CMS OIDC application. bootstrap-authentik.sh creates the matching provider.
CMS_AUTH_ENABLED=true
CMS_BASE_URL=http://cms.local.nodedc:8210
CMS_OIDC_ISSUER=http://cms-auth.local.nodedc:8210/application/o/dc-cms/
CMS_OIDC_CLIENT_ID=dc-cms
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
CMS_OIDC_REDIRECT_URI=http://cms.local.nodedc:8210/auth/callback
CMS_OIDC_LOGGED_OUT_REDIRECT_URI=http://cms.local.nodedc:8210/auth/logged-out
CMS_LOGOUT_URI=http://cms.local.nodedc:8210/auth/logout
CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher
# CMS app session
CMS_SESSION_SECRET=replace-with-random-secret
COOKIE_DOMAIN=
COOKIE_SECURE=false

View File

@ -0,0 +1,52 @@
# public domains routed by Synology Reverse Proxy to 172.22.0.222:9918
CMS_DOMAIN=cms.dcserve.ru
CMS_AUTH_DOMAIN=auth.dcserve.ru
CMS_AUTH_ADMIN_DOMAIN=auth-admin.dcserve.ru
CMS_PUBLIC_URL=https://cms.dcserve.ru
CMS_AUTH_PUBLIC_URL=https://auth.dcserve.ru
CMS_AUTH_ADMIN_PUBLIC_URL=https://auth-admin.dcserve.ru
CMS_HTTP_BIND=172.22.0.222:9918
CMS_AUTH_HTTP_BIND=172.22.0.222:9919
CMS_AUTH_ADMIN_HTTP_BIND=127.0.0.1:9920
CMS_EXTERNAL_SCHEME=https
# mounted NODE.DC site root on Synology
CMS_NODEDC_SITE_PATH=/volume1/docker/dc-cms/sites/nodedc
# authentik image
AUTHENTIK_IMAGE=ghcr.io/goauthentik/server
AUTHENTIK_TAG=2026.2.2
AUTHENTIK_ERROR_REPORTING__ENABLED=false
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=127.0.0.0/8,172.16.0.0/12
# authentik database
PG_DB=authentik
PG_USER=authentik
PG_PASS=replace-with-random-secret
# authentik first-start bootstrap
AUTHENTIK_SECRET_KEY=replace-with-random-secret
AUTHENTIK_BOOTSTRAP_EMAIL=dcctouch@gmail.com
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-temporary-password
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-token
# CMS bootstrap user. Used by infra/scripts/bootstrap-authentik.sh.
CMS_BOOTSTRAP_ADMIN_EMAIL=dcctouch@gmail.com
CMS_BOOTSTRAP_ADMIN_PASSWORD=replace-with-temporary-password
CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin
# CMS OIDC application. bootstrap-authentik.sh creates the matching provider.
CMS_AUTH_ENABLED=true
CMS_BASE_URL=https://cms.dcserve.ru
CMS_OIDC_ISSUER=https://auth.dcserve.ru/application/o/dc-cms/
CMS_OIDC_CLIENT_ID=dc-cms
CMS_OIDC_CLIENT_SECRET=replace-with-random-secret
CMS_OIDC_REDIRECT_URI=https://cms.dcserve.ru/auth/callback
CMS_OIDC_LOGGED_OUT_REDIRECT_URI=https://cms.dcserve.ru/auth/logged-out
CMS_LOGOUT_URI=https://cms.dcserve.ru/auth/logout
CMS_REQUIRED_GROUPS=dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher
# CMS app session
CMS_SESSION_SECRET=replace-with-random-secret
COOKIE_DOMAIN=
COOKIE_SECURE=true

View File

@ -0,0 +1,320 @@
from os import environ
from django.db import transaction
from authentik.brands.models import Brand
from authentik.common.oauth.constants import SubModes
from authentik.core.models import Application, Group, User
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowStageBinding
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
ClientTypes,
IssuerMode,
OAuth2LogoutMethod,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.password.models import PasswordStage
GROUP_SPECS = [
("dc-cms:owner", False),
("dc-cms:admin", False),
("dc-cms:editor", False),
("dc-cms:publisher", False),
("dc-cms:site:nodedc:admin", False),
]
CMS_APP_SPEC = {
"slug": "dc-cms",
"name": "DC CMS",
"provider_name": "DC CMS OIDC",
"client_id_env": "CMS_OIDC_CLIENT_ID",
"client_secret_env": "CMS_OIDC_CLIENT_SECRET",
"redirect_uri_env": "CMS_OIDC_REDIRECT_URI",
"logged_out_redirect_uri_env": "CMS_OIDC_LOGGED_OUT_REDIRECT_URI",
"launch_url_env": "CMS_BASE_URL",
"launch_url": "http://cms.local.nodedc:8210",
"logout_uri_env": "CMS_LOGOUT_URI",
"logout_uri": "http://cms.local.nodedc:8210/auth/logout",
"groups": [
"dc-cms:owner",
"dc-cms:admin",
"dc-cms:editor",
"dc-cms:publisher",
"dc-cms:site:nodedc:admin",
],
"description": "Standalone CMS for NODE.DC websites and SEO knowledge bases.",
}
def required_env(name):
value = environ.get(name, "").strip()
if not value:
raise RuntimeError(f"{name} is required")
return value
def optional_env(name, default=""):
return environ.get(name, default).strip()
def ensure_group(name, is_superuser=False):
group, _ = Group.objects.get_or_create(name=name)
group.is_superuser = is_superuser
group.save()
return group
def ensure_groups():
groups = {}
for name, is_superuser in GROUP_SPECS:
groups[name] = ensure_group(name, is_superuser)
return groups
def ensure_admin_user(groups):
admin_email = (
environ.get("CMS_BOOTSTRAP_ADMIN_EMAIL", "").strip()
or environ.get("AUTHENTIK_BOOTSTRAP_EMAIL", "").strip()
)
if not admin_email:
return None
user = User.objects.filter(email__iexact=admin_email).first() or User.objects.filter(
username=admin_email
).first()
if user is None:
user = User(username=admin_email, email=admin_email, name=admin_email, type="internal")
user.username = admin_email
user.email = admin_email
user.name = environ.get("CMS_BOOTSTRAP_ADMIN_NAME", "DCTOUCH CMS Admin").strip() or admin_email
user.is_active = True
user.type = "internal"
admin_password = (
environ.get("CMS_BOOTSTRAP_ADMIN_PASSWORD", "")
or environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD", "")
)
if admin_password:
user.set_password(admin_password)
user.save()
authentik_admins, _ = Group.objects.get_or_create(name="authentik Admins")
if not authentik_admins.is_superuser:
authentik_admins.is_superuser = True
authentik_admins.save(update_fields=["is_superuser"])
user.groups.add(authentik_admins)
for group in groups.values():
user.groups.add(group)
return user
def ensure_groups_scope_mapping():
mapping, _ = ScopeMapping.objects.get_or_create(
name="DC CMS OAuth Mapping: groups",
defaults={
"scope_name": "groups",
"description": "Adds Authentik group names to DC CMS OIDC tokens.",
"expression": 'return {"groups": [group.name for group in request.user.groups.all()]}',
},
)
mapping.scope_name = "groups"
mapping.description = "Adds Authentik group names to DC CMS OIDC tokens."
mapping.expression = 'return {"groups": [group.name for group in request.user.groups.all()]}'
mapping.save()
return mapping
def ensure_profile_scope_mapping():
expression = """
attributes = request.user.attributes or {}
display_name = request.user.name or request.user.username
name_parts = display_name.split(" ", 1)
avatar_url = attributes.get("picture") or attributes.get("avatar_url") or attributes.get("avatar")
return {
"name": display_name,
"given_name": name_parts[0] if name_parts else display_name,
"family_name": name_parts[1] if len(name_parts) > 1 else "",
"preferred_username": request.user.username,
"nickname": request.user.username,
"picture": avatar_url,
"avatar_url": avatar_url,
}
""".strip()
mapping, _ = ScopeMapping.objects.get_or_create(
name="DC CMS OAuth Mapping: profile context",
defaults={
"scope_name": "profile",
"description": "Adds normalized DC CMS profile claims to OIDC tokens.",
"expression": expression,
},
)
mapping.scope_name = "profile"
mapping.description = "Adds normalized DC CMS profile claims to OIDC tokens."
mapping.expression = expression
mapping.save()
return mapping
def default_scope_mappings():
scope_names = ["openid", "email", "offline_access"]
mappings = list(ScopeMapping.objects.filter(scope_name__in=scope_names))
mappings.append(ensure_profile_scope_mapping())
mappings.append(ensure_groups_scope_mapping())
return mappings
def ensure_cms_brand():
auth_domain = environ.get("CMS_AUTH_DOMAIN", "cms-auth.local.nodedc").strip() or "cms-auth.local.nodedc"
authentication_flow = Flow.objects.get(slug="default-authentication-flow")
invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
authentication_flow.name = "DC CMS authentication"
authentication_flow.title = "Управление сайтами."
authentication_flow.layout = "stacked"
authentication_flow.background = ""
authentication_flow.save()
identification_stage = IdentificationStage.objects.get(
name="default-authentication-identification"
)
password_stage = PasswordStage.objects.get(name="default-authentication-password")
password_stage.allow_show_password = True
password_stage.save()
identification_stage.user_fields = ["email"]
identification_stage.password_stage = password_stage
identification_stage.show_matched_user = False
identification_stage.enable_remember_me = False
identification_stage.save()
FlowStageBinding.objects.filter(target=authentication_flow, stage=password_stage).delete()
brand = Brand.objects.filter(domain=auth_domain).first()
if brand is None:
brand = Brand(domain=auth_domain)
Brand.objects.exclude(brand_uuid=brand.brand_uuid).update(default=False)
brand.default = True
brand.domain = auth_domain
brand.branding_title = "DC CMS"
brand.branding_logo = ""
brand.branding_favicon = ""
brand.branding_custom_css = ""
brand.flow_authentication = authentication_flow
brand.flow_invalidation = invalidation_flow
brand.attributes = {
**(brand.attributes or {}),
"settings": {
**((brand.attributes or {}).get("settings") or {}),
"locale": "ru",
"theme": {
**(((brand.attributes or {}).get("settings") or {}).get("theme") or {}),
"base": "dark",
},
},
}
brand.save()
return brand
def ensure_provider(spec, mappings):
authorization_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
signing_key = (
CertificateKeyPair.objects.filter(name="authentik Self-signed Certificate").first()
or CertificateKeyPair.objects.first()
)
if signing_key is None:
raise RuntimeError("No Authentik CertificateKeyPair exists for OIDC signing")
provider = OAuth2Provider.objects.filter(name=spec["provider_name"]).first()
if provider is None:
provider = OAuth2Provider(name=spec["provider_name"])
provider.name = spec["provider_name"]
provider.client_type = ClientTypes.CONFIDENTIAL
provider.client_id = required_env(spec["client_id_env"])
provider.client_secret = required_env(spec["client_secret_env"])
redirect_uri_values = [required_env(spec["redirect_uri_env"])]
logged_out_redirect_uri = optional_env(spec.get("logged_out_redirect_uri_env", ""), "")
if logged_out_redirect_uri:
redirect_uri_values.append(logged_out_redirect_uri)
provider.redirect_uris = [
RedirectURI(RedirectURIMatchingMode.STRICT, redirect_uri)
for redirect_uri in dict.fromkeys(redirect_uri_values)
]
provider.logout_uri = optional_env(spec.get("logout_uri_env", ""), spec["logout_uri"])
provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL
provider.include_claims_in_id_token = True
provider.sub_mode = SubModes.USER_UUID
provider.issuer_mode = IssuerMode.PER_PROVIDER
provider.authorization_flow = authorization_flow
provider.invalidation_flow = invalidation_flow
provider.signing_key = signing_key
provider.save()
provider.property_mappings.set(mappings)
return provider
def ensure_application(spec, provider, groups):
application = Application.objects.filter(slug=spec["slug"]).first()
if application is None:
application = Application(slug=spec["slug"], name=spec["name"])
application.name = spec["name"]
application.slug = spec["slug"]
application.group = "DC CMS"
application.provider = provider
application.meta_launch_url = optional_env(spec.get("launch_url_env", ""), spec["launch_url"])
application.meta_description = spec["description"]
application.meta_publisher = "DCTOUCH"
application.open_in_new_tab = False
application.policy_engine_mode = "any"
application.save()
PolicyBinding.objects.filter(target=application).exclude(
group__name__in=spec["groups"]
).delete()
for order, group_name in enumerate(spec["groups"]):
binding = PolicyBinding.objects.filter(target=application, group=groups[group_name]).first()
if binding is None:
binding = PolicyBinding(target=application, group=groups[group_name])
binding.enabled = True
binding.negate = False
binding.timeout = 30
binding.failure_result = False
binding.order = order
binding.save()
return application
@transaction.atomic
def main():
brand = ensure_cms_brand()
groups = ensure_groups()
user = ensure_admin_user(groups)
mappings = default_scope_mappings()
provider = ensure_provider(CMS_APP_SPEC, mappings)
application = ensure_application(CMS_APP_SPEC, provider, groups)
summary = {
"groups": list(groups),
"admin_user": user.email if user else None,
"brand": brand.domain,
"application": application.slug,
"provider": provider.name,
}
print(summary)
main()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,948 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<script data-id="nodedc-auth-favicon">
"use strict";
(function () {
const faviconHref = "data:image/svg+xml,%3Csvg%20width%3D%2232%22%20height%3D%2232%22%20viewBox%3D%220%200%2032%2032%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cstyle%3E%0A%20%20%20%20.icon-fav-01%20%7B%20fill%3A%20%23333334%3B%20%7D%0A%20%20%20%20%40media%20(prefers-color-scheme%3A%20dark)%20%7B%20.icon-fav-01%20%7B%20fill%3A%20%23FEFEFE%3B%20%7D%20%7D%0A%20%20%20%20%40media%20(prefers-color-scheme%3A%20light)%20%7B%20.icon-fav-01%20%7B%20fill%3A%20%23000000%3B%20%7D%20%7D%0A%20%20%3C%2Fstyle%3E%0A%20%20%3Cpath%20class%3D%22icon-fav-01%22%20d%3D%22M17.8738%2015.9159L16.0002%2018.9718L14.1267%2015.9159H17.8738ZM23.6307%2012.7864H8.36914L15.9999%2025.2308L23.6307%2012.7864Z%22%2F%3E%0A%20%20%3Cpath%20class%3D%22icon-fav-01%22%20d%3D%22M10.9793%2019.4872L6.67269%2011.5918H25.5344L21.2281%2019.4872H25.0581L31.2614%208H0.738281L7.16244%2019.4872H10.9793Z%22%2F%3E%0A%3C%2Fsvg%3E";
const applyFavicon = () => {
if (!document.head) return;
document.querySelectorAll('link[rel~="icon"]').forEach((node) => node.remove());
const link = document.createElement("link");
link.rel = "icon";
link.type = "image/svg+xml";
link.sizes = "any";
link.href = faviconHref;
document.head.appendChild(link);
};
applyFavicon();
window.addEventListener("DOMContentLoaded", applyFavicon, { once: true });
})();
</script>
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<script data-id="nodedc-auth-flow-scope">
"use strict";
(function () {
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("cms-auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
document.documentElement.classList.toggle("nodedc-auth-flow-page", isAuthExperiencePage);
window.addEventListener("DOMContentLoaded", function () {
document.body?.classList.toggle("nodedc-auth-flow-page", isAuthExperiencePage);
}, { once: true });
})();
</script>
<style data-id="nodedc-auth-login-css">
{% include "branding/nodedc-login.css" %}
</style>
<style data-id="nodedc-auth-critical-loader">
html.nodedc-auth-flow-page {
--ak-global--primary: #f4f4f5 !important;
--ak-global--background: #0e0f10 !important;
--ak-global--background-image: none !important;
--pf-global--primary-color--100: #f4f4f5 !important;
--pf-v5-global--primary-color--100: #f4f4f5 !important;
--pf-v6-global--primary-color--100: #f4f4f5 !important;
--pf-v4-global--palette--blue-300: #f4f4f5 !important;
--ak-global--palette--blue-300: #f4f4f5 !important;
--pf-global--active-color--100: #f4f4f5 !important;
--pf-global--link--Color: #f4f4f5 !important;
--nodedc-auth-primary: #f4f4f5 !important;
--nodedc-auth-bg: #0e0f10 !important;
}
html.nodedc-auth-flow-page,
html.nodedc-auth-flow-page body,
html.nodedc-auth-flow-page body .pf-c-login {
background: #0e0f10 !important;
color-scheme: dark !important;
}
html.nodedc-auth-flow-page body ak-flow-executor::part(main),
html.nodedc-auth-flow-page body .pf-c-login__main,
html.nodedc-auth-flow-page body .pf-c-empty-state,
html.nodedc-auth-flow-page body .pf-v5-c-empty-state,
html.nodedc-auth-flow-page body .pf-v6-c-empty-state,
html.nodedc-auth-flow-page body .pf-c-card,
html.nodedc-auth-flow-page body .pf-v5-c-card,
html.nodedc-auth-flow-page body .pf-v6-c-card,
html.nodedc-auth-flow-page body [class*="empty-state"] {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
html.nodedc-auth-flow-page body .pf-c-spinner,
html.nodedc-auth-flow-page body .pf-v5-c-spinner,
html.nodedc-auth-flow-page body .pf-v6-c-spinner,
html.nodedc-auth-flow-page body [role="progressbar"],
html.nodedc-auth-flow-page body ak-spinner {
--pf-c-spinner--Color: #f4f4f5 !important;
--pf-v5-c-spinner--Color: #f4f4f5 !important;
--pf-v6-c-spinner--Color: #f4f4f5 !important;
--pf-c-spinner__clipper--after--BoxShadowColor: #f4f4f5 !important;
--pf-c-spinner__lead-ball--after--BackgroundColor: #f4f4f5 !important;
--pf-c-spinner__tail-ball--after--BackgroundColor: #f4f4f5 !important;
color: #f4f4f5 !important;
}
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] {
position: fixed !important;
top: 50% !important;
left: 50% !important;
display: block !important;
width: 2.5rem !important;
height: 2.5rem !important;
min-width: 2.5rem !important;
min-height: 2.5rem !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
box-shadow: none !important;
opacity: 1 !important;
visibility: visible !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
transform: translate(-50%, -50%) !important;
overflow: visible !important;
}
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder .pf-c-spinner,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] .pf-c-spinner,
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder [role="progressbar"],
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"] [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
}
html.nodedc-auth-flow-page body #ak-placeholder.ak-c-placeholder::before,
html.nodedc-auth-flow-page body .ak-c-placeholder[slot="placeholder"]::before {
content: "";
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: #f4f4f5;
border-radius: 999px;
animation: nodedc-auth-placeholder-spin 0.72s linear infinite;
pointer-events: none;
}
html.nodedc-auth-flow-page body.nodedc-auth-loading ak-flow-executor::part(main),
html.nodedc-auth-flow-page body.nodedc-auth-loading .pf-c-login__main {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
html.nodedc-auth-flow-page body.nodedc-auth-loading::before {
content: "";
position: fixed;
top: 50%;
left: 50%;
z-index: 1200;
width: 2.5rem;
height: 2.5rem;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: #f4f4f5;
border-radius: 999px;
transform: translate(-50%, -50%);
animation: nodedc-auth-loader-spin 0.72s linear infinite;
pointer-events: none;
}
html.nodedc-auth-flow-page body.nodedc-auth-card-ready::before {
display: none !important;
}
@keyframes nodedc-auth-loader-spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes nodedc-auth-placeholder-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
{% endif %}
{% endif %}
{% endwith %}
<script data-id="authentik-config">
"use strict";
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
const authentikBrand = JSON.parse('{{ brand_json|escapejs }}' || "{}");
if (isAuthExperiencePage) {
const nodedcAuthLoginCss = document.querySelector('style[data-id="nodedc-auth-login-css"]')?.textContent || "";
authentikBrand.branding_custom_css = nodedcAuthLoginCss;
authentikBrand.brandingCustomCss = nodedcAuthLoginCss;
} else {
authentikBrand.branding_custom_css = "";
authentikBrand.brandingCustomCss = "";
}
window.authentik = {
locale: "ru",
config: JSON.parse('{{ config_json|escapejs }}' || "{}"),
brand: authentikBrand,
versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
api: {
base: "{{ base_url }}",
relBase: "{{ base_url_rel }}",
},
};
window.addEventListener("DOMContentLoaded", function () {
{% for message in messages %}
window.dispatchEvent(
new CustomEvent("ak-message", {
bubbles: true,
composed: true,
detail: {
level: "{{ message.tags|escapejs }}",
message: "{{ message.message|escapejs }}",
},
}),
);
{% endfor %}
});
</script>
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<script data-id="nodedc-auth-field-enhancements">
"use strict";
(function () {
const isAuthentikAdminHost =
window.location.hostname.startsWith("auth-admin.") ||
window.location.hostname.startsWith("cms-auth-admin.") ||
window.location.hostname.startsWith("id-admin.") ||
window.location.hostname === "172.22.0.222";
const isAuthExperiencePage =
!isAuthentikAdminHost &&
(
window.location.pathname.includes("/if/flow/") ||
window.location.pathname.includes("/flows/") ||
window.location.pathname.includes("/login/")
);
if (!isAuthExperiencePage) return;
document.documentElement.classList.add("nodedc-auth-flow-page");
window.addEventListener("DOMContentLoaded", function () {
document.body?.classList.add("nodedc-auth-flow-page");
}, { once: true });
const logoSvg = `
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55" aria-hidden="true">
<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"></path>
<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"></polygon>
<path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"></path>
<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>
<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>
<path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"></path>
<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>
<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"></path>
</svg>
`;
const clearIcon = `
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle>
<path d="M9 9l6 6M15 9l-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
`;
const genericLoginError = "Не удалось выполнить вход. Проверьте email и пароль или запросите доступ.";
const revokedLoginError = "Аккаунт больше не активен. Запросите доступ, если хотите подключиться снова.";
const genericFlowErrorMessages = [
"Response returned an error code",
"Response returned an error",
"Не удалось завершить операцию.",
];
const accountStatusCache = new Map();
const authTranslations = new Map([
["Failed to authenticate.", genericLoginError],
["Invalid credentials.", genericLoginError],
["Invalid password.", genericLoginError],
["Incorrect password.", genericLoginError],
["Authentication failed.", genericLoginError],
["This field is required.", "Заполните это поле."],
["Please enter your password", "Введите пароль"],
["Please enter your password.", "Введите пароль."],
["Please enter a valid email address.", "Введите корректную электронную почту."],
["Caps Lock is enabled.", "Включён Caps Lock."],
["Forgot username or password?", "Забыли пароль?"],
["Forgot password?", "Забыли пароль?"],
["Remember me on this device", "Запомнить на этом устройстве"],
["Email", "Эл. почта"],
["Email or Username", "Эл. почта"],
["Password", "Пароль"],
["Log in", "Войти"],
["Continue", "Продолжить"],
["Show password", "Показать пароль"],
["Hide password", "Скрыть пароль"],
["Permission denied", "Доступ запрещён"],
["Not you?", "Сменить пользователя"],
["Request has been denied.", "Доступ запрещён."],
["The request has been denied.", "Доступ запрещён."],
["В произведении запроса было отказано.", "Доступ запрещён."],
["Go home", "Вернуться назад"],
["Response returned an error code", "Не удалось завершить операцию."],
]);
function visitRoots(root, callback) {
callback(root);
root.querySelectorAll("*").forEach((element) => {
if (element.shadowRoot) {
visitRoots(element.shadowRoot, callback);
}
});
}
function ensureLogo() {
if (!document.body || document.querySelector("[data-nodedc-auth-logo='true']")) return;
document.body.classList.add("nodedc-auth-enhanced");
const logo = document.createElement("a");
logo.className = "nodedc-auth-logo";
logo.dataset.nodedcAuthLogo = "true";
logo.href = getLauncherBaseUrl();
logo.setAttribute("aria-label", "DC CMS");
logo.innerHTML = logoSvg;
document.body.appendChild(logo);
updateLogoLink(logo);
}
function getLauncherBaseUrl() {
const hostname = window.location.hostname;
const cmsHostnames = {
"cms-auth.local.nodedc": "cms.local.nodedc",
"cms-auth-admin.local.nodedc": "cms.local.nodedc",
"cms-id.local.nodedc": "cms.local.nodedc",
"auth.dcserve.ru": "cms.dcserve.ru",
"auth-admin.dcserve.ru": "cms.dcserve.ru",
};
const cmsHostname =
cmsHostnames[hostname] ||
(hostname.startsWith("cms-auth.")
? `cms.${hostname.slice(9)}`
: hostname.startsWith("auth.")
? `cms.${hostname.slice(5)}`
: hostname.startsWith("cms-id.")
? `cms.${hostname.slice(7)}`
: "cms.local.nodedc");
const port = window.location.port ? `:${window.location.port}` : "";
return `${window.location.protocol}//${cmsHostname}${port}/`;
}
function updateLogoLink(logo) {
fetch(new URL("/api/public/brand", getLauncherBaseUrl()).toString(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (payload?.logoLinkUrl) {
logo.href = payload.logoLinkUrl;
}
})
.catch(() => {});
}
function translateTextValue(value) {
if (!value) return value;
const trimmed = value.trim();
const translated = authTranslations.get(trimmed);
if (!translated) return value;
return value.replace(trimmed, translated);
}
function translateAuthText(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || !node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT;
if (["SCRIPT", "STYLE", "SVG", "PATH", "POLYGON"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach((node) => {
const translated = translateTextValue(node.nodeValue);
if (translated !== node.nodeValue) {
node.nodeValue = translated;
}
});
root.querySelectorAll("[aria-label], [title], [placeholder], input[value], button").forEach((element) => {
["aria-label", "title", "placeholder"].forEach((attribute) => {
const value = element.getAttribute(attribute);
const translated = translateTextValue(value);
if (translated !== value) element.setAttribute(attribute, translated);
});
});
}
function hasPermissionDeniedScreen() {
let denied = false;
visitRoots(document, (root) => {
if (denied) return;
const text = root.textContent || "";
denied = [
"Permission denied",
"Доступ ограничен",
"Доступ запрещен",
"Доступ запрещён",
"Request has been denied.",
"The request has been denied.",
"В произведении запроса было отказано.",
"Доступ к модулю ограничен.",
].some((message) => text.includes(message));
});
return denied;
}
function syncPermissionDeniedState() {
document.body?.classList.toggle("nodedc-auth-permission-denied", hasPermissionDeniedScreen());
}
function findAncestor(element, predicate, maxDepth = 8) {
let current = element;
for (let depth = 0; current && depth < maxDepth; depth += 1) {
if (predicate(current)) return current;
current = current.parentElement;
}
return null;
}
function applyPermissionDeniedLayout(root) {
const deniedTitleMessages = ["Permission denied", "Доступ ограничен", "Доступ запрещен", "Доступ запрещён"];
const deniedSubtitle = "Доступ запрещён.";
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue?.trim() || "";
return deniedTitleMessages.some((message) => text === message)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach((node) => {
const titleElement = findAncestor(
node.parentElement,
(element) =>
element.matches?.(".pf-c-title, .pf-v5-c-title, h1, h2") ||
element.getAttribute?.("slot") === "title",
);
const header =
titleElement?.closest?.(".pf-c-login__main-header") ||
findAncestor(titleElement || node.parentElement, (element) => element.matches?.(".pf-c-login__main-header"), 10) ||
root.querySelector?.(".pf-c-login__main-header");
if (titleElement) {
titleElement.style.display = "none";
}
if (header) {
header.setAttribute("data-nodedc-permission-denied", "true");
header.setAttribute("data-nodedc-denied-subtitle", deniedSubtitle);
}
});
}
function hidePermissionDeniedReason(root) {
const deniedMessages = [
"Request has been denied.",
"The request has been denied.",
"В произведении запроса было отказано.",
"Доступ к модулю ограничен.",
"Объяснение:",
"Reason:",
"Привязка политики",
"Policy binding",
"returned result",
"returned a result",
"'None' вернула результат",
];
root.querySelectorAll(".pf-c-alert, .pf-v5-c-alert, .pf-c-empty-state__body, [class*='alert']").forEach((element) => {
const text = element.textContent || "";
if (deniedMessages.some((message) => text.includes(message))) {
element.style.display = "none";
}
});
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue?.trim() || "";
return deniedMessages.some((message) => text.includes(message))
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach((node) => {
let element = node.parentElement;
for (let depth = 0; element && depth < 6; depth += 1) {
const text = (element.textContent || "").trim();
if (deniedMessages.some((message) => text === message || text.endsWith(message)) && text.length <= 96) {
element.style.display = "none";
return;
}
element = element.parentElement;
}
if (node.parentElement) {
node.parentElement.style.display = "none";
}
});
}
function enhanceIdentifierField(root) {
const input = root.querySelector("#ak-identifier-input");
if (!input || input.dataset.nodedcClearBound === "true") return;
const fieldGroup = input.closest(".pf-c-form__group") || input.parentElement;
if (!fieldGroup) return;
input.dataset.nodedcClearBound = "true";
fieldGroup.style.position = "relative";
const button = document.createElement("button");
button.type = "button";
button.tabIndex = -1;
button.className = "nodedc-auth-clear-input";
button.dataset.nodedcClearInput = "true";
button.setAttribute("aria-label", "Очистить электронную почту");
button.innerHTML = clearIcon;
const update = () => {
button.dataset.visible = input.value ? "true" : "false";
};
button.addEventListener("click", (event) => {
event.preventDefault();
input.value = "";
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
input.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
input.focus();
update();
});
input.addEventListener("input", update);
input.addEventListener("change", update);
fieldGroup.appendChild(button);
update();
}
function maskPasswordInput(input) {
if (!input || input.type !== "text") return;
input.type = "password";
const button = input.closest(".pf-c-input-group")?.querySelector(".pf-c-button.pf-m-control");
const icon = button?.querySelector("i");
button?.setAttribute("aria-label", "Показать пароль");
icon?.classList.remove("fa-eye-slash");
icon?.classList.add("fa-eye");
}
function maskVisiblePasswords(target) {
visitRoots(document, (root) => {
root.querySelectorAll("#ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
const inputGroup = input.closest(".pf-c-input-group");
if (inputGroup?.contains(target)) return;
maskPasswordInput(input);
});
});
}
function enhancePasswordFields(root) {
root.querySelectorAll("#ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
if (input.placeholder !== "Введите пароль") {
input.placeholder = "Введите пароль";
}
if (input.dataset.nodedcBlurBound === "true") return;
input.dataset.nodedcBlurBound = "true";
input.addEventListener("blur", () => {
window.setTimeout(() => {
const inputGroup = input.closest(".pf-c-input-group");
if (inputGroup?.contains(document.activeElement)) return;
maskPasswordInput(input);
}, 0);
});
});
}
function normalizePasswordFields(root) {
enhancePasswordFields(root);
}
function dispatchSyntheticInput(input) {
if (!input) return;
try {
input.dispatchEvent(new InputEvent("input", {
bubbles: true,
composed: true,
inputType: "insertReplacementText",
data: input.value || "",
}));
} catch {
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
}
input.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
function syncFormFieldsBeforeSubmit(form) {
form.querySelectorAll("#ak-identifier-input, #ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
dispatchSyntheticInput(input);
});
}
function enhanceSubmitHandoff(root) {
root.querySelectorAll("form").forEach((form) => {
if (form.dataset.nodedcSubmitBound === "true") return;
form.dataset.nodedcSubmitBound = "true";
form.addEventListener("submit", () => {
syncFormFieldsBeforeSubmit(form);
document.body?.classList.add("nodedc-auth-submitting");
}, true);
});
}
function ensureAccessRequestLink(root) {
const input = root.querySelector("#ak-identifier-input, #ak-stage-identification-password, #ak-stage-password-input");
const form = input?.closest("form") || root.querySelector("form");
if (!input || !form || form.querySelector("[data-nodedc-access-request='true']")) return;
const link = document.createElement("a");
link.className = "nodedc-auth-request-access";
link.dataset.nodedcAccessRequest = "true";
link.href = "mailto:dcctouch@gmail.com";
link.textContent = "Запросить доступ";
form.appendChild(link);
}
function normalizeEmail(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function getLoginEmail(root) {
return normalizeEmail(root.querySelector("#ak-identifier-input")?.value);
}
function getLoginErrorMessages() {
return [
"Failed to authenticate.",
"Invalid credentials.",
"Invalid password.",
"Incorrect password.",
"Authentication failed.",
"Не удалось выполнить вход.",
"Неверная почта или пароль.",
"Неверный пароль.",
genericLoginError,
revokedLoginError,
];
}
function replaceLoginErrorText(root, message) {
const errorMessages = getLoginErrorMessages();
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const text = node.nodeValue?.trim() || "";
return errorMessages.some((errorMessage) => text.includes(errorMessage))
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach((node) => {
const currentValue = node.nodeValue || "";
const trimmed = currentValue.trim();
if (!trimmed) return;
node.nodeValue = currentValue.replace(trimmed, message);
});
}
function syncLoginErrorReason(root) {
if (!hasAuthError(root)) return;
replaceLoginErrorText(root, genericLoginError);
const email = getLoginEmail(root);
if (!email) return;
const cachedStatus = accountStatusCache.get(email);
if (cachedStatus === "revoked") {
replaceLoginErrorText(root, revokedLoginError);
return;
}
if (cachedStatus === "unknown" || cachedStatus === "loading") return;
accountStatusCache.set(email, "loading");
const statusUrl = new URL("/api/public/login-account-status", getLauncherBaseUrl());
statusUrl.searchParams.set("email", email);
fetch(statusUrl.toString(), { cache: "no-store" })
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
const status = payload?.status === "revoked" ? "revoked" : "unknown";
accountStatusCache.set(email, status);
if (status !== "revoked") return;
visitRoots(document, (candidateRoot) => {
replaceLoginErrorText(candidateRoot, revokedLoginError);
});
})
.catch(() => {
accountStatusCache.set(email, "unknown");
});
}
function hasAuthError(root) {
const hasErrorElement = root.querySelector(
".pf-c-alert, .pf-m-error, .pf-c-helper-text__item.pf-m-error, [aria-invalid='true']"
);
if (hasErrorElement) return true;
const text = root.textContent || "";
return getLoginErrorMessages().some((message) => text.includes(message));
}
function hasGenericFlowError(root) {
const text = root.textContent || "";
return genericFlowErrorMessages.some((message) => text.includes(message));
}
function hasLoadingOnlyScreen(root) {
const isFlowExecutorRoot = root.host?.tagName?.toLowerCase() === "ak-flow-executor";
const hasProgress =
root.querySelector?.(".pf-c-spinner, .pf-v5-c-spinner, [role='progressbar'], ak-spinner");
const isEmptyFlowExecutor = isFlowExecutorRoot && (root.textContent || "").trim() === "";
const hasInteractiveAuth =
root.querySelector?.("input, textarea, select, button[type='submit'], .pf-c-alert, .pf-v5-c-alert") ||
hasGenericFlowError(root);
return Boolean((hasProgress || isEmptyFlowExecutor) && !hasInteractiveAuth);
}
function syncLoadingState() {
let isLoadingOnly = false;
let hasAuthContent = false;
visitRoots(document, (root) => {
if (hasLoadingOnlyScreen(root)) {
isLoadingOnly = true;
}
if (
root.querySelector?.("input, textarea, select, button[type='submit'], .pf-c-alert, .pf-v5-c-alert") ||
hasGenericFlowError(root)
) {
hasAuthContent = true;
}
});
const shouldShowLoader = isLoadingOnly && !hasAuthContent;
document.body?.classList.toggle("nodedc-auth-loading", shouldShowLoader);
document.body?.classList.toggle("nodedc-auth-card-ready", hasAuthContent);
}
function restoreCardOnErrors(root) {
if (hasAuthError(root)) {
document.body?.classList.remove("nodedc-auth-submitting");
}
}
function isOidcLogoutFlow() {
const path = window.location.pathname;
const params = new URLSearchParams(window.location.search);
return (
path.includes("/end-session/") ||
path.includes("/if/flow/default-invalidation-flow") ||
params.has("post_logout_redirect_uri")
);
}
function getSafePostLogoutRedirect() {
const rawUrl = new URLSearchParams(window.location.search).get("post_logout_redirect_uri");
if (!rawUrl) return null;
try {
const url = new URL(rawUrl);
const allowedHosts = new Set([
"cms.local.nodedc",
"cms.nodedc.ru",
"dc-cms.local.nodedc",
"cms.dcserve.ru",
]);
if (!["http:", "https:"].includes(url.protocol)) return null;
if (!allowedHosts.has(url.hostname) && url.hostname !== new URL(getLauncherBaseUrl()).hostname) return null;
if (!url.pathname.startsWith("/auth/logged-out")) return null;
return url.toString();
} catch {
return null;
}
}
function buildLoggedOutRedirectUrl() {
return new URL("/auth/logged-out", getLauncherBaseUrl()).toString();
}
function redirectGenericFlowError(root) {
if (document.body?.dataset.nodedcFlowErrorRedirected === "true") return;
if (!window.location.pathname.includes("/if/flow/")) return;
if (!hasGenericFlowError(root)) return;
document.body.dataset.nodedcFlowErrorRedirected = "true";
document.body.classList.add("nodedc-auth-submitting");
window.setTimeout(() => {
window.location.replace(getSafePostLogoutRedirect() || buildLoggedOutRedirectUrl());
}, 120);
}
function redirectCompletedLogout(root) {
if (!isOidcLogoutFlow() || document.body?.dataset.nodedcLogoutRedirected === "true") return;
const redirectUrl = getSafePostLogoutRedirect();
if (!redirectUrl) return;
const isInvalidationFlow = window.location.pathname.includes("/if/flow/default-invalidation-flow");
const text = root.textContent || "";
const logoutComplete = [
"Logout successful",
"You've logged out",
"You have been logged out",
"Logged out",
"Вы вышли",
"Выход выполнен",
].some((message) => text.includes(message));
if (!logoutComplete && !isInvalidationFlow) return;
document.body.dataset.nodedcLogoutRedirected = "true";
document.body.classList.add("nodedc-auth-submitting");
window.setTimeout(() => {
window.location.replace(redirectUrl);
}, isInvalidationFlow ? 1600 : 150);
}
let scheduled = false;
function enhanceAuthFields() {
scheduled = false;
ensureLogo();
visitRoots(document, (root) => {
enhanceIdentifierField(root);
normalizePasswordFields(root);
enhanceSubmitHandoff(root);
ensureAccessRequestLink(root);
translateAuthText(root);
syncLoginErrorReason(root);
applyPermissionDeniedLayout(root);
hidePermissionDeniedReason(root);
restoreCardOnErrors(root);
redirectCompletedLogout(root);
redirectGenericFlowError(root);
});
syncPermissionDeniedState();
syncLoadingState();
}
function scheduleEnhancement() {
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(enhanceAuthFields);
}
window.addEventListener("DOMContentLoaded", scheduleEnhancement);
window.addEventListener("load", scheduleEnhancement);
document.addEventListener("pointerdown", (event) => {
const target = event.composedPath?.()[0] || event.target;
maskVisiblePasswords(target);
}, true);
window.addEventListener("blur", () => maskVisiblePasswords(null));
new MutationObserver(scheduleEnhancement).observe(document.documentElement, {
childList: true,
subtree: true,
});
})();
</script>
{% endif %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,50 @@
{% load static %}
{% load i18n %}
{% load authentik_core %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html
lang="{{ LANGUAGE_CODE }}"
data-theme="{% if ui_theme == "dark" %}dark{% else %}light{% endif %}"
data-theme-choice="{% if ui_theme == "dark" %}dark{% elif ui_theme == "light" %}light{% else %}auto{% endif %}"
>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
<meta name="darkreader-lock">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
{% block head_before %}
{% endblock %}
{% include "base/theme.html" %}
{% with request_host=request.get_host %}
{% if request_host|slice:":11" != "auth-admin." and request_host|slice:":15" != "cms-auth-admin." and request_host|slice:":9" != "id-admin." and request_host|slice:":12" != "172.22.0.222" %}
{% if request.path|slice:":9" == "/if/flow/" or request.path|slice:":7" == "/flows/" or request.path|slice:":7" == "/login/" %}
<style data-id="brand-css">{{ brand_css }}</style>
{% else %}
<style data-id="brand-css"></style>
{% endif %}
{% else %}
<style data-id="brand-css"></style>
{% endif %}
{% endwith %}
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
{% block head %}
{% endblock %}
{% for key, value in html_meta.items %}
<meta name="{{key}}" content="{{ value }}" />
{% endfor %}
</head>
<body>
{% block body %}
{% endblock %}
{% block scripts %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,737 @@
:root,
:host {
--ak-global--primary: #f4f4f5;
--ak-global--primary-hover: #ffffff;
--ak-global--background: #0e0f10;
--ak-global--background-image: none !important;
--ak-c-login--MaxWidth: 28rem;
--ak-c-login--spacer: 2.2rem;
--ak-c-login__main--BackgroundColor: transparent;
--ak-c-login__main--Color: #f3f3f3;
--ak-c-login__main--BoxShadow: none;
--ak-c-login__footer--PaddingBlock: 0;
--pf-global--primary-color--100: #f4f4f5;
--pf-global--primary-color--200: #ffffff;
--pf-v4-global--palette--blue-300: #f4f4f5;
--ak-global--palette--blue-300: #f4f4f5;
--pf-global--active-color--100: #f4f4f5;
--pf-global--link--Color: #f4f4f5;
--pf-global--link--Color--hover: #ffffff;
--pf-global--BackgroundColor--100: #0e0f10;
--pf-global--BackgroundColor--light-100: transparent;
--pf-global--Color--100: #f3f3f3;
--pf-global--Color--200: #9d9da3;
--pf-global--BorderRadius--lg: 1.9rem;
--pf-global--BorderRadius--sm: 1.15rem;
--pf-v5-global--BorderRadius--lg: 1.9rem;
--pf-v5-global--BorderRadius--sm: 1.15rem;
--pf-v6-global--BorderRadius--large: 1.9rem;
--pf-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
--pf-c-form-control--BorderColor: transparent;
--pf-c-form-control--BorderBottomColor: transparent;
--pf-v5-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
--pf-v5-c-form-control--BorderColor: transparent;
--pf-v5-c-form-control--BorderBottomColor: transparent;
--nodedc-auth-bg: #0e0f10;
--nodedc-auth-card-bg: rgba(9, 9, 12, 0.84);
--nodedc-auth-primary: #f4f4f5;
--nodedc-auth-primary-hover: #ffffff;
--nodedc-auth-on-primary: rgb(11, 17, 23);
--nodedc-auth-text-primary: #e4e6e7;
--nodedc-auth-text-secondary: #cacdce;
--nodedc-auth-text-placeholder: #959a9d;
color-scheme: dark;
}
html,
body,
.pf-c-login {
background: var(--nodedc-auth-bg) !important;
color: var(--nodedc-auth-text-primary) !important;
font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
:host {
background: transparent !important;
color: inherit !important;
font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
:host(.pf-c-login) {
display: flex !important;
min-width: 100vw !important;
min-height: 100vh !important;
align-items: center !important;
justify-content: center !important;
background: var(--nodedc-auth-bg) !important;
color: var(--nodedc-auth-text-primary) !important;
}
body::before,
.pf-c-login::before {
background: none !important;
}
body:not(.nodedc-auth-enhanced)::after {
content: "";
display: block;
position: fixed;
top: 1.7875rem;
left: calc(1.9375rem - 2px);
width: 7.25rem;
height: 1.79rem;
background: center / contain no-repeat url("data:image/svg+xml,%3Csvg id='nodedc-logo' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220.82 54.55'%3E%3Cpath fill='%23e2e1e1' d='M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z'/%3E%3Cpolygon fill='%23e2e1e1' 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'/%3E%3Cpath fill='%23dbdbdb' d='M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15Zm24.08.15c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-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.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09Zm20.39 17.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.75Zm14.92-7.49c0-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.54-4.89 0-8.01-3.59-8.01-8.89Z'/%3E%3C/svg%3E");
image-rendering: auto;
z-index: 20;
pointer-events: none;
}
.nodedc-auth-logo {
position: fixed;
display: block;
top: 1.7875rem;
left: calc(1.9375rem - 2px);
width: 7.25rem;
height: 1.79rem;
z-index: 20;
line-height: 0;
text-decoration: none;
pointer-events: auto;
color: var(--nodedc-auth-text-primary);
}
.nodedc-auth-logo svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
shape-rendering: geometricPrecision;
text-rendering: geometricPrecision;
}
.pf-c-page__drawer,
.pf-c-drawer,
.pf-c-drawer__main,
.pf-c-drawer__content,
.pf-c-drawer__body {
background: transparent !important;
}
.pf-c-drawer__body {
display: flex !important;
min-height: 100vh !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
ak-flow-executor.pf-c-login::before,
:host(.pf-c-login)::before {
display: none !important;
}
ak-flow-executor.pf-c-login {
display: flex !important;
width: 100% !important;
min-height: 100vh !important;
position: relative !important;
align-items: center !important;
justify-content: center !important;
padding-right: 0 !important;
box-sizing: border-box !important;
}
ak-brand-links,
ak-flow-inspector,
ak-locale-context,
ak-locale-switcher,
ak-locale-select,
ak-flow-executor [slot="footer"],
.pf-c-login__footer,
.pf-c-login__header,
.pf-c-login__main-header.pf-c-brand,
.pf-c-form > p:first-child {
display: none !important;
}
ak-flow-executor::part(locale-select),
ak-flow-executor::part(footer),
ak-flow-executor::part(branding) {
display: none !important;
}
ak-flow-executor::part(main),
.pf-c-login__main {
position: fixed !important;
top: 50% !important;
left: 50% !important;
right: auto !important;
transform: translate(-50%, -50%) !important;
width: min(100%, 28rem) !important;
max-width: 28rem !important;
min-height: 0 !important;
margin: 0 !important;
padding: 2.2rem !important;
box-sizing: border-box !important;
overflow: hidden !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.9rem !important;
background: transparent !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
justify-content: flex-start !important;
}
body.nodedc-auth-card-ready ak-flow-executor::part(main),
body.nodedc-auth-card-ready .pf-c-login__main {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%),
var(--nodedc-auth-card-bg) !important;
-webkit-backdrop-filter: blur(40px) !important;
backdrop-filter: blur(40px) !important;
}
ak-flow-card,
ak-stage-identification,
ak-stage-password {
display: block !important;
width: 100% !important;
background: transparent !important;
}
.pf-c-login__main-body,
.pf-c-form,
.pf-c-form__group,
.pf-c-form__group-control,
.pf-c-input-group,
.pf-c-input-group__item,
.pf-c-card,
.pf-c-card__body,
.pf-c-empty-state,
.pf-c-empty-state__content,
.pf-c-empty-state__body,
.pf-v5-c-empty-state,
.pf-v5-c-empty-state__content,
.pf-v5-c-empty-state__body,
.pf-v6-c-empty-state,
.pf-v6-c-empty-state__content,
.pf-v6-c-empty-state__body {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
}
.pf-c-login__main-header {
display: block !important;
width: 100% !important;
min-width: 0 !important;
padding: 0 0 1.75rem !important;
}
.pf-c-login__main-header .pf-c-title,
.pf-c-title.pf-m-3xl,
h1.pf-c-title {
display: block !important;
width: 100% !important;
max-width: 22rem !important;
margin: 0 !important;
color: var(--nodedc-auth-text-primary) !important;
font-size: 2rem !important;
line-height: 2.5rem !important;
font-weight: 650 !important;
letter-spacing: -0.045em !important;
text-wrap: balance !important;
white-space: normal !important;
word-break: normal !important;
overflow-wrap: normal !important;
hyphens: none !important;
overflow: visible !important;
line-clamp: unset !important;
-webkit-line-clamp: unset !important;
-webkit-box-orient: initial !important;
text-transform: none !important;
}
.pf-c-login__main-header::after {
content: "Вход в панель управления сайтами.";
display: block;
max-width: 22rem;
margin-top: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-size: 1.9rem;
line-height: 2.5rem;
font-weight: 650;
letter-spacing: -0.045em;
text-transform: none;
}
.pf-c-login__main-header[data-nodedc-permission-denied="true"]::after,
body.nodedc-auth-permission-denied .pf-c-login__main-header::after {
content: attr(data-nodedc-denied-subtitle) !important;
margin-top: 0;
}
body.nodedc-auth-permission-denied .pf-c-login__main-header::after {
content: "Доступ запрещён." !important;
}
.pf-c-login__main-header[data-nodedc-permission-denied="true"] .pf-c-title,
.pf-c-login__main-header[data-nodedc-permission-denied="true"] .pf-c-title.pf-m-3xl,
.pf-c-login__main-header[data-nodedc-permission-denied="true"] h1.pf-c-title,
body.nodedc-auth-permission-denied .pf-c-login__main-header .pf-c-title,
body.nodedc-auth-permission-denied .pf-c-title.pf-m-3xl,
body.nodedc-auth-permission-denied h1.pf-c-title {
display: none !important;
}
body.nodedc-auth-permission-denied ak-flow-executor::part(main),
body.nodedc-auth-permission-denied .pf-c-login__main {
background: transparent !important;
border: 0 !important;
outline: 0 !important;
box-shadow: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.pf-c-login__main-body {
padding: 0 !important;
}
.pf-c-form {
display: flex !important;
flex-direction: column !important;
gap: 1.05rem !important;
}
.pf-c-form__group,
ak-flow-input-password.pf-c-form__group {
margin: 0 !important;
}
.pf-c-form__label,
.pf-c-form__label-text,
label {
color: var(--nodedc-auth-text-placeholder) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 400 !important;
letter-spacing: -0.01em !important;
text-transform: none !important;
}
.pf-c-form__label-required {
display: none !important;
}
label[for="ak-identifier-input"],
label[for="ak-stage-identification-password"] {
font-size: 0 !important;
}
label[for="ak-identifier-input"] .pf-c-form__label-text,
label[for="ak-identifier-input"] .pf-c-form__label-required,
label[for="ak-stage-identification-password"] .pf-c-form__label-text,
label[for="ak-stage-identification-password"] .pf-c-form__label-required {
display: none !important;
}
label[for="ak-identifier-input"]::after {
content: "Эл. почта";
font-size: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-weight: 400;
}
label[for="ak-stage-identification-password"]::after {
content: "Пароль";
font-size: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-weight: 400;
}
.pf-c-form-control,
.pf-c-input-group .pf-c-form-control,
input.pf-c-form-control {
width: 100% !important;
min-height: 3rem !important;
padding: 0 1rem !important;
border: 0 !important;
border-bottom: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.15rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.03) !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
color: var(--nodedc-auth-text-primary) !important;
font-size: 0.875rem !important;
font-style: normal !important;
font-weight: 400 !important;
text-transform: none !important;
caret-color: var(--nodedc-auth-primary) !important;
}
.pf-c-form-control::placeholder,
input.pf-c-form-control::placeholder {
color: var(--nodedc-auth-text-placeholder) !important;
font-style: normal !important;
opacity: 1 !important;
}
.pf-c-form-control:focus,
input.pf-c-form-control:focus {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.03) !important;
border: 0 !important;
border-bottom: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.pf-c-form-control:-webkit-autofill,
.pf-c-form-control:-webkit-autofill:hover,
.pf-c-form-control:-webkit-autofill:focus,
input.pf-c-form-control:-webkit-autofill,
input.pf-c-form-control:-webkit-autofill:hover,
input.pf-c-form-control:-webkit-autofill:focus {
border: 0 !important;
outline: none !important;
-webkit-text-fill-color: var(--nodedc-auth-text-primary) !important;
caret-color: var(--nodedc-auth-primary) !important;
transition: background-color 999999s ease-in-out 0s !important;
box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
-webkit-box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
}
.pf-c-input-group {
position: relative !important;
display: block !important;
border: 0 !important;
box-shadow: none !important;
}
.pf-c-form__group:has(#ak-identifier-input) {
position: relative !important;
}
.pf-c-input-group .pf-c-button.pf-m-control {
position: absolute !important;
top: 0.5rem !important;
right: 0.75rem !important;
display: grid !important;
width: 2rem !important;
min-width: 2rem !important;
height: 2rem !important;
place-items: center !important;
padding: 0 !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
color: var(--nodedc-auth-text-placeholder) !important;
box-shadow: none !important;
text-decoration: none !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control::before,
.pf-c-input-group .pf-c-button.pf-m-control::after,
.pf-c-input-group .pf-c-button.pf-m-control:hover::before,
.pf-c-input-group .pf-c-button.pf-m-control:hover::after,
.pf-c-input-group .pf-c-button.pf-m-control:focus::before,
.pf-c-input-group .pf-c-button.pf-m-control:focus::after {
display: none !important;
border: 0 !important;
box-shadow: none !important;
content: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control:hover,
.pf-c-input-group .pf-c-button.pf-m-control:focus,
.pf-c-input-group .pf-c-button.pf-m-control:active {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
text-decoration: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control i,
.nodedc-auth-clear-input svg {
width: 1.25rem !important;
height: 1.25rem !important;
color: var(--nodedc-auth-text-placeholder) !important;
opacity: 0.9 !important;
}
.pf-c-input-group input.pf-c-form-control {
padding-right: 3rem !important;
}
.nodedc-auth-clear-input {
position: absolute !important;
right: 0.75rem !important;
bottom: 0.5rem !important;
display: none !important;
width: 2rem !important;
height: 2rem !important;
padding: 0 !important;
place-items: center !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
color: var(--nodedc-auth-text-placeholder) !important;
box-shadow: none !important;
cursor: pointer !important;
}
.nodedc-auth-clear-input[data-visible="true"] {
display: grid !important;
}
.pf-c-button.pf-m-primary,
button.pf-m-primary,
button[type="submit"],
.pf-c-form .pf-c-button.pf-m-primary {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-height: 3.25rem !important;
margin-top: 1.25rem !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.25rem !important;
background: var(--nodedc-auth-primary) !important;
color: var(--nodedc-auth-on-primary) !important;
font-size: 0.95rem !important;
font-weight: 600 !important;
line-height: 1 !important;
letter-spacing: -0.02em !important;
text-align: center !important;
text-transform: none !important;
}
.pf-c-button.pf-m-primary:hover,
button.pf-m-primary:hover,
button[type="submit"]:hover {
background: var(--nodedc-auth-primary-hover) !important;
color: var(--nodedc-auth-on-primary) !important;
}
.nodedc-auth-request-access {
display: inline-flex !important;
justify-content: center !important;
width: 100% !important;
margin-top: -0.35rem !important;
color: var(--nodedc-auth-primary) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 650 !important;
letter-spacing: -0.01em !important;
text-decoration: none !important;
}
.nodedc-auth-request-access:hover,
.nodedc-auth-request-access:focus {
color: var(--nodedc-auth-primary-hover) !important;
text-decoration: none !important;
}
.pf-c-alert,
.pf-c-alert.pf-m-inline {
border: 0 !important;
border-radius: 1rem !important;
margin: 0 0 1.15rem !important;
padding: 0 !important;
background: transparent !important;
color: var(--nodedc-auth-primary) !important;
box-shadow: none !important;
}
.pf-c-alert__icon,
.pf-c-alert__title,
.pf-c-alert__description {
color: var(--nodedc-auth-primary) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 600 !important;
}
body.nodedc-auth-permission-denied .pf-c-alert,
body.nodedc-auth-permission-denied .pf-v5-c-alert,
body.nodedc-auth-permission-denied .pf-c-alert.pf-m-inline {
display: none !important;
}
a,
.pf-c-button.pf-m-link,
button.pf-m-link {
color: var(--nodedc-auth-primary) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 600 !important;
text-decoration: none !important;
}
body.nodedc-auth-submitting ak-flow-executor::part(main),
body.nodedc-auth-loading ak-flow-executor::part(main),
body.nodedc-auth-submitting .pf-c-login__main,
body.nodedc-auth-loading .pf-c-login__main,
body.nodedc-auth-submitting .pf-c-empty-state,
body.nodedc-auth-loading .pf-c-empty-state,
body.nodedc-auth-submitting .pf-c-spinner,
body.nodedc-auth-loading .pf-c-spinner,
body.nodedc-auth-submitting [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
body.nodedc-auth-loading [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
.pf-c-spinner,
.pf-v5-c-spinner,
[role="progressbar"],
ak-spinner {
--pf-c-spinner--Color: var(--nodedc-auth-primary) !important;
--pf-v5-c-spinner--Color: var(--nodedc-auth-primary) !important;
--pf-c-spinner__clipper--after--BoxShadowColor: var(--nodedc-auth-primary) !important;
--pf-c-spinner__lead-ball--after--BackgroundColor: var(--nodedc-auth-primary) !important;
--pf-c-spinner__tail-ball--after--BackgroundColor: var(--nodedc-auth-primary) !important;
color: var(--nodedc-auth-primary) !important;
}
#ak-placeholder.ak-c-placeholder,
.ak-c-placeholder[slot="placeholder"] {
position: fixed !important;
top: 50% !important;
left: 50% !important;
display: block !important;
width: 2.5rem !important;
height: 2.5rem !important;
min-width: 2.5rem !important;
min-height: 2.5rem !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
box-shadow: none !important;
opacity: 1 !important;
visibility: visible !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
transform: translate(-50%, -50%) !important;
overflow: visible !important;
}
#ak-placeholder.ak-c-placeholder .pf-c-spinner,
.ak-c-placeholder[slot="placeholder"] .pf-c-spinner,
#ak-placeholder.ak-c-placeholder [role="progressbar"],
.ak-c-placeholder[slot="placeholder"] [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
}
#ak-placeholder.ak-c-placeholder::before,
.ak-c-placeholder[slot="placeholder"]::before {
content: "";
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: var(--nodedc-auth-primary);
border-radius: 999px;
animation: nodedc-auth-placeholder-spin 0.72s linear infinite;
pointer-events: none;
}
body.nodedc-auth-submitting::before,
body.nodedc-auth-loading::before {
content: "";
position: fixed;
top: 50%;
left: 50%;
z-index: 1200;
width: 2.5rem;
height: 2.5rem;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: var(--nodedc-auth-primary);
border-radius: 999px;
transform: translate(-50%, -50%);
animation: nodedc-auth-loader-spin 0.72s linear infinite;
pointer-events: none;
}
@keyframes nodedc-auth-loader-spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes nodedc-auth-placeholder-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
ak-library-application,
ak-application-wizard,
ak-user-interface,
.pf-c-page__sidebar,
.pf-c-page__header,
.pf-c-nav,
.pf-c-brand {
color-scheme: dark;
}
@media (max-width: 640px) {
ak-flow-executor.pf-c-login::before,
:host(.pf-c-login)::before {
display: none !important;
}
body::after,
.nodedc-auth-logo {
top: 1.7875rem;
left: calc(1.9375rem - 2px);
width: 7.25rem;
height: 1.79rem;
}
ak-flow-executor::part(main),
.pf-c-login__main {
position: fixed !important;
top: 50% !important;
right: auto !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: calc(100vw - 2rem) !important;
padding: 1.5rem !important;
}
}

View File

@ -0,0 +1,16 @@
{% extends "base/skeleton.html" %}
{% load authentik_core %}
{% block head %}
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-skip-to-content></ak-skip-to-content>
<ak-message-container alignment="bottom"></ak-message-container>
<ak-interface-admin>
{% include "base/placeholder.html" %}
</ak-interface-admin>
{% endblock %}

144
infra/docker-compose.yml Normal file
View File

@ -0,0 +1,144 @@
name: dc-cms
services:
reverse-proxy:
image: caddy:2-alpine
restart: unless-stopped
env_file:
- .env
ports:
- "${CMS_HTTP_BIND:-127.0.0.1:8210}:80"
- "${CMS_AUTH_HTTP_BIND:-127.0.0.1:8211}:80"
- "${CMS_AUTH_ADMIN_HTTP_BIND:-127.0.0.1:8212}:80"
volumes:
- ./reverse-proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
cms-app:
condition: service_started
authentik-server:
condition: service_started
networks:
cms-edge:
aliases:
- ${CMS_DOMAIN:-cms.local.nodedc}
- ${CMS_AUTH_DOMAIN:-cms-auth.local.nodedc}
- ${CMS_AUTH_ADMIN_DOMAIN:-cms-auth-admin.local.nodedc}
cms-app:
build:
context: ..
restart: unless-stopped
env_file:
- .env
environment:
HOST: 0.0.0.0
PORT: 8090
SITE_ROOT: /sites/nodedc
CMS_AUTH_ENABLED: ${CMS_AUTH_ENABLED:-true}
CMS_OIDC_ISSUER: ${CMS_OIDC_ISSUER:?cms oidc issuer required}
CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required}
CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required}
CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required}
CMS_OIDC_LOGGED_OUT_REDIRECT_URI: ${CMS_OIDC_LOGGED_OUT_REDIRECT_URI:?cms oidc logged out redirect uri required}
CMS_REQUIRED_GROUPS: ${CMS_REQUIRED_GROUPS:-dc-cms:owner,dc-cms:admin,dc-cms:editor,dc-cms:publisher}
CMS_SESSION_SECRET: ${CMS_SESSION_SECRET:?cms session secret required}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
expose:
- "8090"
volumes:
- ../projects:/app/projects
- ${CMS_NODEDC_SITE_PATH:-../../NODEDC_SITE}:/sites/nodedc
depends_on:
authentik-server:
condition: service_started
networks:
- cms-edge
postgresql-authentik:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_DB: ${PG_DB:-authentik}
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
- authentik-database:/var/lib/postgresql/data
networks:
- cms-edge
authentik-server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: server
restart: unless-stopped
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,172.16.0.0/12}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
depends_on:
postgresql-authentik:
condition: service_healthy
expose:
- "9000"
- "9443"
shm_size: 512mb
volumes:
- authentik-data:/data
- ./authentik/custom-templates:/templates:ro
networks:
- cms-edge
authentik-worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
command: worker
restart: unless-stopped
user: root
env_file:
- .env
environment:
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
depends_on:
postgresql-authentik:
condition: service_healthy
shm_size: 512mb
volumes:
- authentik-data:/data
- authentik-certs:/certs
- ./authentik/custom-templates:/templates:ro
- ./authentik/bootstrap-cms.py:/bootstrap/bootstrap-cms.py:ro
networks:
- cms-edge
networks:
cms-edge:
volumes:
authentik-database:
authentik-data:
authentik-certs:
caddy-data:
caddy-config:

View File

@ -0,0 +1,39 @@
{
auto_https off
}
http://{$CMS_DOMAIN:cms.local.nodedc} {
reverse_proxy cms-app:8090 {
header_up Host {host}
header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http}
header_up X-Forwarded-For {remote_host}
}
}
http://{$CMS_AUTH_DOMAIN:cms-auth.local.nodedc} {
@auth_admin path /if/admin /if/admin/*
redir @auth_admin {$CMS_AUTH_ADMIN_PUBLIC_URL}{uri} 302
@auth_root path /
redir @auth_root {$CMS_PUBLIC_URL}/ 302
@auth_user_dashboard path /if/user /if/user/*
redir @auth_user_dashboard {$CMS_PUBLIC_URL}/ 302
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http}
header_up X-Forwarded-For {remote_host}
}
}
http://{$CMS_AUTH_ADMIN_DOMAIN:cms-auth-admin.local.nodedc} {
@admin_root path /
redir @admin_root /if/admin/ 302
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {$CMS_EXTERNAL_SCHEME:http}
header_up X-Forwarded-For {remote_host}
}
}

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFRA_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${INFRA_DIR}"
docker compose --env-file .env -f docker-compose.yml exec -T authentik-worker python /bootstrap/bootstrap-cms.py

24
package-lock.json generated Normal file
View File

@ -0,0 +1,24 @@
{
"name": "dc-cms",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dc-cms",
"version": "0.1.0",
"dependencies": {
"jose": "^6.2.3"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
}
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "dc-cms",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"admin": "node server/admin-server.mjs",
"start": "node server/admin-server.mjs"
},
"dependencies": {
"jose": "^6.2.3"
}
}

18
projects/nodedc.json Normal file
View File

@ -0,0 +1,18 @@
{
"id": "nodedc",
"name": "NODE.DC",
"role": "Администратор сайта",
"siteRoot": "../NODEDC_SITE",
"previewUrl": "http://127.0.0.1:8210/",
"publicUrl": "https://nodedc.ru/",
"deploy": {
"enabled": false,
"method": "sftp",
"host": "",
"port": 22,
"username": "",
"passwordEnv": "MCHOST_PASSWORD",
"remotePath": "",
"deployOnRender": false
}
}

2221
server/admin-server.mjs Normal file

File diff suppressed because it is too large Load Diff