Собираем закрытую админку на Vue с помощью сценариев

У каждого проекта на DynapiCMS есть встроенная админка. Иногда проекту нужна вторая, своя, с интерфейсом под конкретную задачу и доступом для узкого круга людей. Эта статья — пошаговый разбор, как собрать такую админку из того, что уже есть в платформе: типы контента, сценарии, статика сайта. Повторять можно по ходу чтения, все файлы демо лежат в репозитории dynapi-vue-admin, ветки повторяют шаги.
Строим каталог библиотеки: Vue-приложение со списком книг, вход по email и паролю, добавление и удаление записей. GraphQL при этом остаётся внутри платформы.
Шаг 1. Типы контента
Заводим два типа. «Книга»: title и author (строки, обязательные), year (целое число), status (список из двух вариантов: in_stock подписан «В наличии», on_loan — «На руках», по умолчанию in_stock). «Читатель» — учётная запись сотрудника: email (строка, обязательное поле), password, verified.

Поле password имеет тип bcrypt: значение хешируется при записи, а читать хеш из ответов может только сервисный ключ рендера. В браузер он не попадает ни при каком запросе.
Шаг 2. Вход и выход из пресета
В редакторе сценариев есть галерея пресетов. Пресет «Вход» собирает готовую цепочку и предлагает создать недостающий тип одной кнопкой — тот самый email, password и verified.

Почему пресет, а не сценарий с нуля. Цепочка входа — то место, где самодельное решение дороже всего стоит при ошибке: сравнение пароля с хешем, срок жизни сессии, поведение при неудаче, защита от подделки. Пресет собирает проверенную конфигурацию этих узлов, и это не чёрный ящик — обычный документ сценария, который можно открыть в редакторе и доработать. В демо мы меняем один узел: redirect после успешного входа ведёт на /admin, а не на главную.
Результат выглядит так: GraphQL-запрос ищет запись по email из формы, условие сверяет пароль оператором bcrypt_matches, set_cookie ставит сессию, redirect ведёт на страницу успеха. Не сошлось — redirect на /login?failed=1.

Кука называется member, живёт 30 дней и помечена HttpOnly, Secure и SameSite=Lax. Внутри не данные читателя, а подписанная AES-обёртка: из логов и истории браузера из неё ничего не вытащить, а прочитать содержимое может только сервер проекта.
Шаг 3. JSON-API на сценариях
Каталогу нужны три операции: список, добавление, удаление. Каждая — обычный маршрут сайта со сценарием. Сценарий (в коде — флоу) составляется из узлов; для каталога хватает двух действий: graphql обращается к данным проекта, respond_json отвечает клиенту.
Сценарий списка привязан к GET /api/books и целиком помещается в один файл:
{
"version": 2,
"nodes": [
{ "id": "n_start", "type": "entry", "data": {} },
{
"id": "a_q",
"type": "action",
"data": {
"action_type": "graphql",
"config": {
"query": "query { books(limit: 200, orderBy: [{field: CREATED_AT, direction: DESC}]) { id title author year status } }",
"result_key": "books"
}
}
},
{
"id": "a_out",
"type": "action",
"data": {
"action_type": "respond_json",
"config": { "status": 200, "body_expr": "results.books" }
}
}
],
"edges": [
{ "from": "n_start", "to": "a_q", "branch": "" },
{ "from": "a_q", "to": "a_out", "branch": "" }
]
}
Это формат экспорта (.dynflow.json) — тот же документ, что лежит за графом в редакторе и открывается кнопкой «Экспорт». В редакторе он выглядит как карточки на холсте.
body_expr — новый способ собрать JSON-ответ. Это expr (expr-lang): значение вычисляется как данные и кодируется в JSON целиком — числа остаются числами, массивы массивами, а невалидный JSON невозможен в принципе. Liquid-форма body_template: "{{ results.books | json }}" — наследие: с v1.4.16 Liquid из «Тела (шаблон)» «JSON-ответа» удалён из движка, ключ стал статичной строкой, и в старых демо-ветках репозитория такая запись уехала бы клиенту буквально. Новые сценарии начинают с body_expr. Разделение простое: документы (письма, тексты, cookie/redirect) шаблонизирует Liquid, значения считает expr.
Тела POST-запросов разбираются в неймспейсы. Форма с сайта попадает в form., JSON в json., XML в xml.*. Приложение шлёт fetch с Content-Type: application/json, и поля читаются как json.title, json.author. Запрос с чужим Content-Type сервер отвечает кодом 415.
Сценарий добавления принимает POST /api/books/add. Карту переменных мутации целиком строит variables_expr — одна expr-строка. Поля приходят типами: number() строго приводит строку к числу, ?? подставляет правую часть, когда слева nil.
{
"id": "a_create",
"type": "action",
"data": {
"action_type": "graphql",
"config": {
"query": "mutation($title: String!, $author: String!, $year: Int, $status: String) { createBook(input: {title: $title, author: $author, year: $year, status: $status}) { id title author year status } }",
"variables_expr": "{ \"title\": json.title, \"author\": json.author, \"year\": number(json.year) ?? 0, \"status\": json.status ?? \"in_stock\" }",
"result_key": "created"
}
}
}
За ним стоит узел respond_json с body_expr: "results.created" — клиент получает созданную запись и обновляет список (Liquid-форма body_template: "{{ results.created | json }}" — наследие: с v1.4.16 не рендерится, тело собирает body_expr). Удаление устроено так же: deleteBook по json.id и короткий ответ {ok: true}.
Legacy-путь — те же поля Liquid-строками в объекте variables: "year": "{{json.year | plus: 0}}", "status": "{{json.status | default: \"in_stock\"}}". Он работает, но остаётся для существующих сценариев: Liquid в переменных — строковая сборка, variables_expr передаёт типизированные значения.

Маршруты собираются в одну карту и публикуются одной командой. Для закрытых маршрутов ставится membersOnly с адресом страницы входа:
[
{ "path": "/login", "methods": ["GET", "POST"], "cache": { "defaultTtl": 0 },
"flows": [ { "handle": "login_page", "methods": ["GET"] }, { "handle": "login", "methods": ["POST"] } ] },
{ "path": "/api/books", "methods": ["GET"], "cache": { "defaultTtl": 0 },
"membersOnly": true, "loginPath": "/login",
"flows": [ { "handle": "books_list", "methods": ["GET"] } ] }
]
Полная карта из шести маршрутов — в репозитории, файл files/routes.json; flowctl route save files/routes.json --publish публикует её разом.
Проверять сценарии до публикации можно командой flowctl dryrun books_add.dynflow.json --context ctx.json — её запускает тот, кто собирает админку, а платформа исполняет документ клоном движка без побочных эффектов. Контекст — симуляция запроса: метод, тип тела и само тело строкой:
{ "method": "POST", "bodyContentType": "application/json", "body": "{\"title\":\"Тест\",\"author\":\"Кто-то\",\"year\":1984}" }
В отчёте видно, как отработал каждый узел и какие директивы сценарий выдал бы клиенту. GraphQL-узлы в прогоне мокаются — если подсунуть ожидаемый результат в graphqlResults контекста, условия пойдут по реальным веткам. После публикации журнал живёт в админке, вкладка «Запуски»: сколько раз сработал узел, какая ветка выбрана в условии, длительность, дамп запроса и ответа с замаскированными секретами.
Шаг 4. Обёртка: Liquid-шаблон и статика
SPA собирается Vite (Vue 3 + TypeScript) и заливается в статику сайта одной командой:
flowctl files push dist --as static --publish
Файлы ложатся в /static/, откуда сайт раздаёт их до обработки маршрутов. Маршрут /admin отдаёт Liquid-шаблон-обёртку — вот он целиком:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Каталог — Библиотека</title>
<link rel="stylesheet" href="/static/admin.css?v=4">
</head>
<body>
<div id="app"></div>
<script>
window.__BOOT__ = {
member: {{ member | json }},
email: {{ results.me.members[0].email | json }}
};
</script>
<script type="module" src="/static/admin.js?v=4"></script>
</body>
</html>
Mustache-конфликта здесь нет, хотя Liquid разбирает {{ }} как свои вставки и наивный перенос Vue-шаблона в HTML-файл сломался бы на первом же {{ title }}. Конфликта не возникает, потому что Vite компилирует шаблоны .vue в render-функции ещё на сборке: в готовом admin.js декоративных скобок нет, а шаблон-обёртка содержит только пустой div#app. Приложению без сборщика (Vue с CDN и шаблоном прямо в HTML) понадобились бы v-pre или собственные delimiters.
Ссылки на статику версионированы (?v=4), поэтому после перевыкладки сборки браузер не держит старый admin.js в кэше. Суффикс для раздающего сервера прозрачен, отдаётся тот же файл. Скрипт window.__BOOT__ в середине шаблона наполняется данными сервера, об этом шаг 7.
Шаг 5. Приложение на Vue
Приложению не нужны ни Apollo, ни свой слой работы с куки: все запросы идут в те же маршруты, кука member едет с каждым fetch сама, потому что приложение и API живут на одном origin. Весь слой данных — три функции в src/App.vue:
const boot = (window as any).__BOOT__ ?? {};
const books = ref<Book[]>([]);
const form = ref({ title: "", author: "", year: "" });
async function load() {
const res = await fetch("/api/books", { headers: { Accept: "application/json" } });
if (res.redirected || res.status === 403) {
window.location.href = "/login";
return;
}
books.value = (await res.json()).books ?? [];
}
async function add() {
const res = await fetch("/api/books/add", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
title: form.value.title,
author: form.value.author,
year: form.value.year ? Number(form.value.year) : null,
}),
});
if (res.ok) {
form.value = { title: "", author: "", year: "" };
await load();
}
}
async function remove(id: string) {
await fetch("/api/books/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
await load();
}
onMounted(load);
Перенаправление на /login — реакция на гейт: когда сессия кончилась, API не отдаёт данные, а уводит на страницу входа. В шаблоне компонента остаётся обычная таблица над этим состоянием:
<tr v-for="b in books" :key="b.id">
<td class="title">{{ b.title }}</td>
<td>{{ b.author }}</td>
<td class="num">{{ b.year ?? "—" }}</td>
<td><span class="pill" :class="b.status === 'on_loan' ? 'loan' : 'stock'">{{ statusLabel(b.status) }}</span></td>
<td class="num"><button class="del" @click="remove(b.id)">Удалить</button></td>
</tr>


Шаг 6. Стили и темы
Разметку держит CSS из того же .vue-файла: блок Vite выносит в отдельный admin.css, тот самый, что подключён в обёртке. Темы живут на CSS-переменных, и это тот же контракт, что у платформы: атрибут data-theme на со значениями dynamica-light и dynamica-dark.
:root, [data-theme="dynamica-light"] {
--bg: #f4f5f8; --panel: #fff; --text: #1a2233;
--pill-stock-bg: #e5f5ea; --pill-stock-fg: #1d7a3d;
--pill-loan-bg: #fdf1dc; --pill-loan-fg: #96690f;
}
[data-theme="dynamica-dark"] {
--bg: #0d1528; --panel: #16203a; --text: #e8ebf4;
--pill-stock-bg: #163524; --pill-stock-fg: #67d98c;
--pill-loan-bg: #3a2f14; --pill-loan-fg: #eec27a;
}
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.pill.stock { background: var(--pill-stock-bg); color: var(--pill-stock-fg); }
.pill.loan { background: var(--pill-loan-bg); color: var(--pill-loan-fg); }
При старте приложение читает тот же localStorage-ключ dynamica-theme, что и остальной сайт, и выставляет атрибут — тёмная тема включается сама. Если проект собирается на Tailwind, подойдёт тот же приём с data-theme и @theme-переменными; демо обходится голым CSS, чтобы не тянуть зависимости в статью.
Шаг 7. Данные при заходе: boot-пэйлоад
Список книг приложение догружает само, а вот данные о том, кто открыл страницу, можно отдать сразу, ещё до первого fetch. Маршрут /admin привязан к GET-сценарию из двух узлов:
{
"version": 2,
"nodes": [
{ "id": "n_start", "type": "entry", "data": {} },
{
"id": "a_me",
"type": "action",
"data": {
"action_type": "graphql",
"config": {
"query": "query($id: ID!) { members(where: {id: {eq: $id}}, limit: 1) { id email } }",
"variables": { "id": "{{ member.id }}" },
"result_key": "me"
}
}
},
{
"id": "a_render",
"type": "action",
"data": {
"action_type": "render",
"config": { "template": "admin.liquid" }
}
}
],
"edges": [
{ "from": "n_start", "to": "a_me", "branch": "" },
{ "from": "a_me", "to": "a_render", "branch": "" }
]
}
Механика такая. Внутри сценария сессия доступна как member.*: то, что login-пресет положил в куку, движок раскладывает в контекст, и {{ member.id }} подставляет id читателя в переменную запроса. graphql-узел выбирает профиль, результат оседает в results.me, и render-узел рендерит шаблон с этим же контекстом. Шаблон складывает обе величины в window.__BOOT__, а приложение читает их при монтировании и рисует email в шапке — без whoami-запроса.
Два случая стоит держать в голове. Если graphql-узел упал (on_error: continue), results.me будет пуст, в __BOOT__ уедет email: null, а интерфейс покажет нейтральное «сотрудник». А для страниц, собираемых из библиотеки запросов (queryRefs маршрута), есть и зарезервированная переменная $member: платформа подставляет в неё id сессии и перезаписывает клиентское значение, так что фильтр по чужому id через неё собрать не выйдет.
Почему это закрыто
Анонимный GET на members_only-странице уводится на страницу входа (302). Анонимный POST отклоняется до того, как сценарий начнёт работать: JSON-клиент получает 403 AUTHENTICATION_REQUIRED, браузерная форма — 303 на адрес входа. Бюджет запросов на такое обращение не тратится.
Идентификация живёт на сервере. В сценарии контекст member.* собирается из подписанной куки, которую браузер не читает (HttpOnly) и не подделает. На query-пути клиентское значение $member перезаписывается id сессии. Ответы, зависящие от сессии, в общий кэш страниц не попадают: один читатель не увидит чужую версию страницы.
Про капчу стоит знать заранее. Если она включена в настройках рендера, каждый публичный POST, включая вход, обязан нести токен, иначе сервер отвечает 403 CAPTCHA_FAILED. POST с чужого Origin отклоняется кодом REQUEST_ORIGIN_DENIED, это механизм против login-CSRF. В примере из статьи капча выключена, для продакшена её добавляет пресет «Форма с капчей».
Репозиторий и файлы
Все файлы демо собраны в dynapi-vue-admin: flows/*.dynflow.json — сценарии в формате импорта (flowctl apply flows/books_list.dynflow.json --publish), files/routes.json — карта маршрутов, templates/ — обёртка и страница входа, spa/ — приложение, seed/ — записи для первички. Ветки повторяют шаги статьи: step-2-auth — после входа и выхода, step-3-api — после JSON-API и маршрутов, step-4-spa — после обёртки и приложения, main — всё вместе. Гайд по пресету «Вход» — в документации.
Все записи