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

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

В первой статье каталог библиотеки получил вход по паролю — но единственного читателя завели руками, в админке. Живому проекту этого мало: люди регистрируются сами, забывают пароль, а удалять книги должен не каждый. Эта статья достраивает аккаунты до конца: регистрация с подтверждением email, восстановление пароля по ссылке, роли. Инструменты те же — типы контента, сценарии и шаблоны сайта, ни строки серверного кода.

Все файлы — в репозитории dynapi-vue-admin, ветка step-5-accounts. Она продолжает main: сначала пройдите шаги первой статьи.

Шаг 1. Поля для имени и роли

Открываем тип «Читатель» в админке и добавляем два поля. name — строка для имени, оно пригодится в шапке админки. role — список с двумя вариантами: значение reader с подписью «Читатель» и librarian с подписью «Библиотекарь», по умолчанию reader.

Редактор типа «Читатель»: поле role со списком вариантов Читатель и Библиотекарь, значение по умолчанию — Читатель

Значение хранится в записи, подписа видна только редактору в админке. Сценарии будут сравнивать именно значение — 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>

Страница регистрации каталога: заполненная форма с именем, email и паролем

Сценарий (в коде — флоу) вешается на 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 — когда почта ещё не подтверждена.

Три состояния страницы входа по параметру адреса: письмо отправлено, email подтверждён, вход отклонён до подтверждения

Шаг 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, где токен проверяется ещё раз, уже вместе со сменой пароля. Проверять ссылку второй раз обязательно: между открытием формы и отправкой пароля всё может измениться, и завершающий сценарий не должен верить состоянию страницы.

Страница /reset по ссылке из письма: поле «Новый пароль» и кнопка сохранения

У механизма есть следствие, о котором стоит знать: ссылка подписана текущим хешем пароля, поэтому смена пароля убивает все выданные ссылки — и подтверждения, и сбросы. Прочитал письмо позже часа, дважды запросил сброс, сменил пароль — старые ссылки молча становятся недействительными. Отдельной таблицы одноразовых ссылок вести не нужно.

Шаг 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 со временем отправки, редирект.

Журнал запусков сценария register: узлы graphql, send_email и редирект со статусом «ок»

Живой каталог стоит прогнать по всему циклу: зарегистрироваться своим ящиком, кликнуть ссылку, войти, запросить сброс, сменить пароль и войти новым. Старый пароль после смены должен перестать работать — если работает, где-то потеряна перепроверка.

Что осталось за кадром

Полная карта маршрутов выросла до одиннадцати: пять страниц с формами и состояниями, две страницы ссылок, API каталога и закрытая админка. Все .dynflow.json лежат в ветке step-5-accounts, применяются знакомой командой flowctl apply flows/register.dynflow.json --publish.

Механика ссылок не требует таблиц и чисток: токен устаревает сам. Пароли не покидают платформу — в письмах и журналах их нет, из ответов GraphQL хеш не читается. Защита от чужого Origin на публичных POST включена платформой всегда, а капча — настройка рендера; обе разобраны в первой статье.

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


Все записи