Добавляем аккаунты читателей с помощью сценариев

В первой статье каталог библиотеки получил вход по паролю — но единственного читателя завели руками, в админке. Живому проекту этого мало: люди регистрируются сами, забывают пароль, а удалять книги должен не каждый. Эта статья достраивает аккаунты до конца: регистрация с подтверждением email, восстановление пароля по ссылке, роли. Инструменты те же — типы контента, сценарии и шаблоны сайта, ни строки серверного кода.
Все файлы — в репозитории dynapi-vue-admin, ветка step-5-accounts. Она продолжает main: сначала пройдите шаги первой статьи.
Шаг 1. Поля для имени и роли
Открываем тип «Читатель» в админке и добавляем два поля. name — строка для имени, оно пригодится в шапке админки. role — список с двумя вариантами: значение reader с подписью «Читатель» и librarian с подписью «Библиотекарь», по умолчанию reader.

Значение хранится в записи, подписа видна только редактору в админке. Сценарии будут сравнивать именно значение — librarian.
Шаг 2. Страница регистрации
Регистрацию собираем руками, без пресета: пресет хорош там, где конфигурация типовая, а здесь мы хотим своё письмо, свои состояния страницы и поле имени. Страница — обычный Liquid-шаблон с формой из трёх полей:
<form method="post" action="/register">
<label for="name">Имя</label>
<input id="name" name="name" type="text" required autocomplete="name">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="password">Пароль</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
<button type="submit">Зарегистрироваться</button>
</form>

Сценарий (в коде — флоу) вешается на POST /register и делает четыре вещи: проверяет, что адрес свободен, создаёт запись, отправляет письмо со ссылкой подтверждения, возвращает на страницу входа с пометкой «проверьте почту». В редакторе цепочка выглядит так:

Проверка адреса — graphql-узел плюс условие:
{
"id": "c_taken",
"type": "condition",
"data": {
"predicate": {
"combinator": "and",
"clauses": [
{ "field": "results.m.members", "operator": "ne", "value": "[]" }
]
}
}
}
Список найденных по email записей сравнивается с пустым массивом: ne "[]" — значит «кто-то с таким адресом уже есть», и сценарий уходит в ветку then с редиректом на /register?taken=1. Пароль уходит в запись типа bcrypt — поле хешируется при записи, и хеш не прочитает ни браузер, ни письмо.
Шаг 3. Письмо со ссылкой подтверждения
Аккаунт создаётся с verified = false, и вход для него закрыт, пока читатель не кликнет ссылку из письма. Письмо отправляет узел send_email — у него четыре части: адрес, тема, текст и блок smtp с вашим почтовым сервером:
{
"id": "a_mail",
"type": "action",
"data": {
"action_type": "send_email",
"config": {
"to": ["{{form.email}}"],
"subject": "Подтверждение регистрации — каталог библиотеки",
"body_template": "Здравствуйте, {{form.name}}!\n\nПодтвердите email по ссылке (действует 24 часа):\nhttps://biblio.dynapi.ru/verify?token={{ results.m.members[0].password | signed_token: results.m.members[0].id, \"verify\", 86400 }}\n\nЕсли вы не регистрировались — просто удалите это письмо.",
"smtp": {
"host": "mail.dynapi.ru",
"port": 587,
"username": "biblio@dynapi.ru",
"password": "secret://biblio-smtp-password",
"from": "biblio@dynapi.ru",
"from_name": "Каталог библиотеки"
}
}
}
}
Здесь два механизма, которых не было в первой статье, — фильтр signed_token и поле пароля. Фильтр получает на вход хеш пароля записи, её id, назначение ссылки и срок жизни в секундах, а собирает из этого подписанную ссылку: токен завязан на конкретного читателя и на его текущий хеш. Поле password в smtp-блоке — не пароль, а ссылка на секрет проекта: сам пароль мы положили в хранилище секретов одной командой и никогда не писали в файлы:
cat smtp-password.txt | flowctl secret set biblio-smtp-password
Секреты write-only: назад они не читаются, в экспорт сценария и в журнал прогонов попадает только ссылка secret://biblio-smtp-password, а значение подставляется в момент отправки. Сценарий с такой ссылкой можно коммитить и экспортировать — пароль останется на проекте.
Шаг 4. Страница подтверждения и вход
Ссылка ведёт на GET /verify со сценарием из шести узлов. Первый — graphql: в переменную id сценарий получает предвычисленный субъект ссылки; параметр из строки запроса в этом узле не участвует. Платформа открывает токен, достаёт id читателя и отдаёт сценарию как {{ token_subject }} — подделать его нельзя, токен подписан. Дальше условие проверяет саму ссылку:
{
"field": "query.token",
"operator": "token_valid",
"value": "verify|{{results.m.member.password}}"
}
Оператор token_valid получает назначение и текущий хеш записи через вертикальную черту и решает, жива ли ссылка: не истёк ли срок и не сменился ли пароль. Ветка then помечает запись verified = true, ставит куку member на 30 дней и уводит на /login?verified=1. Кука здесь базовая, только с id: имя и роль в неё добавит вход, поэтому страница и ведёт на /login, а не сразу в каталог. Ветка else — на /login?verified=invalid, у страницы входа есть состояние и для этого.
Логин-сценарий из первой статьи получает второе условие: после проверки пароля он смотрит verified. Пароль верный, письмо не подтверждено — редирект на /login?unverified=1 вместо каталога. И кука теперь несёт больше: set_cookie кладёт в неё имя и роль, чтобы админке не приходилось за ними ходить:
{
"id": "a_cookie",
"type": "action",
"data": {
"action_type": "set_cookie",
"config": {
"name": "member",
"value": "{\"id\":\"{{results.login.members[0].id}}\",\"name\":{{results.login.members[0].name | json}},\"role\":\"{{results.login.members[0].role}}\"}",
"ttl_days": 30,
"path": "/",
"session": true
}
}
}
Состояния страницы входа различаются параметром адреса: ?check_email=1 после регистрации, ?verified=1 после клика по ссылке, ?unverified=1 — когда почта ещё не подтверждена.

Шаг 5. Восстановление пароля
Сценарий на POST /forgot — три узла: найти запись по email, отправить письмо, редирект на /login?reset=sent. Для адреса, которого нет в каталоге, письмо не собирается: подписать ссылку не с чего, хеш пароля принадлежит записи, а записи нет. Страница при этом отвечает то же «отправлено» — так восстановление не превращается в способ перебора чужих адресов.
Письмо сброса собирает тот же фильтр, но с назначением reset и сроком один час:
https://biblio.dynapi.ru/reset?token={{ results.m.members[0].password | signed_token: results.m.members[0].id, "reset", 3600 }}
GET /reset валидирует токен тем же условием token_valid и показывает форму нового пароля. Токен и id читателя форма уносит в скрытых полях — отправка уходит на POST /reset/complete, где токен проверяется ещё раз, уже вместе со сменой пароля. Проверять ссылку второй раз обязательно: между открытием формы и отправкой пароля всё может измениться, и завершающий сценарий не должен верить состоянию страницы.

У механизма есть следствие, о котором стоит знать: ссылка подписана текущим хешем пароля, поэтому смена пароля убивает все выданные ссылки — и подтверждения, и сбросы. Прочитал письмо позже часа, дважды запросил сброс, сменил пароль — старые ссылки молча становятся недействительными. Отдельной таблицы одноразовых ссылок вести не нужно.
Шаг 6. Роль библиотекаря
Кука несёт роль, и сценарии могут её проверять. Сценарий удаления книги получает условие первым узлом, до любого graphql:
{
"id": "c_role",
"type": "condition",
"data": {
"predicate": {
"combinator": "and",
"clauses": [
{ "field": "member.role", "operator": "eq", "value": "librarian" }
]
}
}
}
Поле member.* собирается из подписанной куки на сервере, клиент его не выбирает. Ветка then удаляет книгу, ветка else отвечает кодом 403 и JSON с объяснением:
{
"id": "a_forbidden",
"type": "action",
"data": {
"action_type": "respond_json",
"config": {
"status": 403,
"body_template": "{\"error\": \"FORBIDDEN_ROLE\", \"message\": \"Удалять книги может только библиотекарь\"}"
}
}
}
Приложение на Vue читает ту же роль из boot-пэйлоада — мы добавили name и role в window.__BOOT__ шаблона админки — и просто не рисует кнопку удаления читателю:
const isLibrarian = computed(() => boot.member?.role === "librarian");
<td class="num">
<button v-if="isLibrarian" class="del" @click="remove(b.id)">Удалить</button>
</td>
Скрытая кнопка влияет только на интерфейс; за безопасность отвечает сценарий — читатель, собравший запрос руками, упрётся в 403. Если после смены роли в админке интерфейс не обновился, поможет выход и новый вход: роль уезжает в куку при логине.


Шаг 7. Проверка перед публикацией и после
Сценарии регистрации и сброса проверяются dry-run’ом до публикации — тот же flowctl dryrun, что в первой статье. В контекст на этот раз кладём и заготовку результата graphql-узла, чтобы условие пошло по нужной ветке:
{
"method": "POST",
"body": "name=%D0%90%D0%BD%D0%BD%D0%B0&email=anna%40biblio.test&password=parol-1234",
"bodyContentType": "application/x-www-form-urlencoded",
"graphqlResults": { "m": { "members": [] } }
}
Отчёт покажет все шесть узлов, письмо в прогоне подавлено и помечено как suppressed. Для reset-сценария полезен и обратный прогон: токен-заглушка в контексте уводит условие в ветку else — так проверяется, что неверная ссылка не меняет пароль.
После публикации у сценария появляется журнал — вкладка «Запуски» в редакторе. Удачный прогон регистрации выглядит так: graphql-узлы по порядку, send_email со временем отправки, редирект.

Живой каталог стоит прогнать по всему циклу: зарегистрироваться своим ящиком, кликнуть ссылку, войти, запросить сброс, сменить пароль и войти новым. Старый пароль после смены должен перестать работать — если работает, где-то потеряна перепроверка.
Что осталось за кадром
Полная карта маршрутов выросла до одиннадцати: пять страниц с формами и состояниями, две страницы ссылок, API каталога и закрытая админка. Все .dynflow.json лежат в ветке step-5-accounts, применяются знакомой командой flowctl apply flows/register.dynflow.json --publish.
Механика ссылок не требует таблиц и чисток: токен устаревает сам. Пароли не покидают платформу — в письмах и журналах их нет, из ответов GraphQL хеш не читается. Защита от чужого Origin на публичных POST включена платформой всегда, а капча — настройка рендера; обе разобраны в первой статье.
В следующей статье разберём API-эндпоинты подробнее: фильтры, пагинация и чтение чужих ошибок в журнале прогонов.
Все записи