Закрытая админка на Vue собирается на сценариях вашего сайта
У каждого проекта на DynapiCMS есть встроенная админка. Иногда проекту нужна вторая, своя, с интерфейсом под конкретную задачу и доступом для узкого круга людей. Такая админка собирается из того, что уже есть в платформе: типы контента, сценарии и статика сайта. Писать для неё отдельный бэкенд не нужно.
Разберём весь путь на примере каталога библиотеки: Vue-приложение со списком книг, вход по email и паролю, добавление и удаление записей. По ходу покажем и обвязку (шаблон-обёртку на Liquid), и код самого приложения, и то, как передать ему данные ещё до первого запроса. GraphQL при этом остаётся внутри платформы.
Учётные записи: тип «Читатель»
Закрытая админка начинается с вопроса, кого пускать. Заводим тип контента «Читатель» с тремя полями: email (строка, обязательное поле), password и verified.

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

Кука называется member, живёт 30 дней и помечена HttpOnly, Secure и SameSite=Lax. Внутри не данные читателя, а подписанная AES-обёртка: из логов и истории браузера из неё ничего не вытащить, а прочитать содержимое может только сервер проекта.
Странице входа отдельный интерфейс не нужен: маршрут /login принимает два метода, GET отдаёт Liquid-шаблон с обычной HTML-формой, POST уходит в сценарий. От формы сценарию нужны только имена полей:
<form method="post" action="/login">
<input name="email" type="email" required>
<input name="password" type="password" required>
<button>Войти</button>
</form>
Всё, что должно быть видно только после входа, помечается на маршруте флагом members_only с адресом страницы входа. Дальше платформа делает остальное сама.
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_template": "{{ results.books | json }}" }
}
}
],
"edges": [
{ "from": "n_start", "to": "a_q", "branch": "" },
{ "from": "a_q", "to": "a_out", "branch": "" }
]
}
Тела POST-запросов разбираются в неймспейсы. Форма с сайта попадает в form., JSON в json., XML в xml.*. Приложение шлёт fetch с Content-Type: application/json, и поля читаются как json.title, json.author. Запрос с чужим Content-Type сервер отвечает кодом 415.
Сценарий добавления принимает POST /api/books/add. Поля из JSON-тела подставляются в переменные мутации, результат уходит обратно:
{
"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": {
"title": "{{json.title}}",
"author": "{{json.author}}",
"year": "{{json.year | plus: 0}}",
"status": "{{json.status | default: \"in_stock\"}}"
},
"result_key": "created"
}
}
}
За ним стоит узел respond_json с body_template {{ results.created | json }} — клиент получает созданную запись и обновляет список. Удаление устроено так же: deleteBook по json.id и короткий ответ {ok: true}.

Сценарий проверяется через dry-run ещё до публикации: прогон идёт без побочных эффектов и показывает шаги с директивами. У опубликованных сценариев есть журнал прогонов, где видно, сколько раз сработал каждый узел и на чём упало.
Обёртка: 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__ в середине шаблона наполняется данными сервера, об этом отдельный раздел ниже.
Приложение на Vue
Приложению не нужен ни Apollo, ни даже свой слой работы с куки: все запросы идут в те же маршруты, кука member едет с каждым fetch сама, потому что приложение и API живут на одном origin. Весь слой данных — три функции:
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>


Данные при заходе: 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. В примере из статьи капча выключена, для продакшена её добавляет пресет «Форма с капчей».
Правки без релизов
Типы, сценарии и маршруты остаются в админке проекта и правятся в любой момент. Поменять поля каталога или добавить операцию значит изменить тип или сценарий, без выкладки нового кода. Готовые пресеты входа, регистрации и сброса пароля разобраны в гайде по пресету «Вход».
Все записи