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

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

У каждого проекта на DynapiCMS есть встроенная админка. Иногда проекту нужна вторая, своя, с интерфейсом под конкретную задачу и доступом для узкого круга людей. Эта статья — пошаговый разбор, как собрать такую админку из того, что уже есть в платформе: типы контента, сценарии, статика сайта. Повторять можно по ходу чтения, все файлы демо лежат в репозитории dynapi-vue-admin, ветки повторяют шаги.

Строим каталог библиотеки: Vue-приложение со списком книг, вход по email и паролю, добавление и удаление записей. GraphQL при этом остаётся внутри платформы.

Шаг 1. Типы контента

Заводим два типа. «Книга»: title и author (строки, обязательные), year (целое число), status (список из двух вариантов: in_stock подписан «В наличии», on_loan — «На руках», по умолчанию in_stock). «Читатель» — учётная запись сотрудника: email (строка, обязательное поле), password, verified.

Редактор типа «Читатель» в админке: поля email (string, обязательно), password (bcrypt), verified (boolean)

Поле password имеет тип bcrypt: значение хешируется при записи, а читать хеш из ответов может только сервисный ключ рендера. В браузер он не попадает ни при каком запросе.

Шаг 2. Вход и выход из пресета

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

Галерея пресетов в редакторе сценариев: карточки «Страница», «Вход», «Регистрация», «Забыли пароль», «Смена пароля», «Выход»

Почему пресет, а не сценарий с нуля. Цепочка входа — то место, где самодельное решение дороже всего стоит при ошибке: сравнение пароля с хешем, срок жизни сессии, поведение при неудаче, защита от подделки. Пресет собирает проверенную конфигурацию этих узлов, и это не чёрный ящик — обычный документ сценария, который можно открыть в редакторе и доработать. В демо мы меняем один узел: redirect после успешного входа ведёт на /admin, а не на главную.

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

Редактор сценария входа: узлы Начало, GraphQL, Условие bcrypt_matches, Установка cookie member и два Редиректа

Кука называется 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 передаёт типизированные значения.

Редактор сценария books_add: graphql-мутация createBook и узел respond_json, маршрут /api/books/add

Маршруты собираются в одну карту и публикуются одной командой. Для закрытых маршрутов ставится 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>

Код слоя данных в редакторе: boot-пэйлоад, состояние каталога и функция load

Каталог книг на поддомене проекта: email читателя в шапке, заполненная форма добавления и таблица с четырьмя книгами

Шаг 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 — всё вместе. Гайд по пресету «Вход» — в документации.


Все записи