Skip to content

IAM — авторизация, организации, роли и права ​

Это модуль, с которого начинается любая сессия: подтвердить, кто ты, а дальше выбрать (или оказаться помещённым в) организацию. Всё ниже опирается на apps/core-api/src/modules/auth и .../organizations, .../roles.

Как сделать... ​

Сгруппировано так же, как технические разделы ниже — иконка помощи каждой страницы ведёт прямо в свою группу, а не во весь этот список.

Аутентификация ​

Создать аккаунт — /auth/sign-up → впиши email и пароль → Создать аккаунт → проверь почту — там письмо со ссылкой подтверждения. См. гайд по полям.

Войти без пароля — на /auth/sign-in нажми Войти по magic link или Войти по коду вместо пароля, впиши email, проверь почту. См. гайд по полям.

Войти через Google — Продолжить с Google на /auth/sign-in.

Восстановить забытый пароль — Забыли пароль? на /auth/sign-in → впиши email → перейди по ссылке из письма. См. гайд по полям.

Создать аккаунт — гайд по полям ​
  • Email — если он уже зарегистрирован, тебе прямо об этом сообщат при отправке.
  • Пароль — от 8 до 128 символов. Других требований нет (не нужны обязательные заглавные буквы, цифры или символы).
  • После отправки приходит письмо с подтверждением и появляется ссылка Отправить письмо повторно — можно нажимать до 3 раз в минуту. Регистрация также незаметно создаёт для тебя личное рабочее пространство (см. Что такое P4P).
Войти без пароля — гайд по полям ​

Оба варианта — magic link и одноразовый код — полностью заменяют форму пароля. Выбери один способ для этого визита.

  • Magic link — впиши email → Отправить ссылку. Ты всегда увидишь «Если такой аккаунт существует, ссылка для входа отправлена», есть аккаунт или нет — это сохраняет приватность в любом случае. Ссылка действует 15 минут.
  • Одноразовый код — впиши email → Отправить код → появляется поле для 6-значного кода. Код действует 10 минут, а запрос нового мгновенно отменяет предыдущий.
  • Оба способа ограничены 3 отправками в минуту, показанными как обратный отсчёт на кнопке повторной отправки.
Восстановить забытый пароль — гайд по полям ​
  • Запрос (/auth/forgot-password) — впиши email → Отправить ссылку для сброса. То же сообщение-подтверждение, что и выше, есть аккаунт на этот адрес или нет.
  • Новый пароль (по ссылке из письма) — впиши и подтверди новый пароль (8–128 символов, оба раза одинаково). Если ссылка отсутствует, сломана или уже использована, ты увидишь ошибку и сможешь запросить новую.

Главная ​

Открыть своё рабочее пространство — /dashboard → карточка пространства в блоке Мои рабочие пространства. Это страница, на которую попадаешь после входа, и ссылка «домой» в меню аватара возвращает сюда же.

Найти пространство в длинном списке — поле Поиск рабочих пространств… над списком. Оно фильтрует по названию по мере ввода; счётчик над ним всегда показывает, во скольких пространствах ты реально состоишь, а не сколько осталось после поиска.

Перейти в Администрирование платформы или Управление командой — карточки сверху. Ты видишь только те, к которым есть доступ: Администрирование платформы требует platform:admin:access, Управление командой — workspace:staffing:read, а подрядчик со связанной анкетой в кадровом резерве вместо них получает карточку Профиль в резерве.

Попасть в пространство, которого не видно — самостоятельно добавиться нельзя. Попроси владельца пространства пригласить тебя; подсказка внизу списка говорит о том же.

Личный кабинет ​

Отредактировать профиль — /account/profile → обнови фото, имя, телефон, часовой пояс, био, локацию, дату рождения, навыки и области интересов.

Выбрать язык — /account/profile → Язык → выбери из списка. Сохраняется сразу же — отдельной кнопки Сохранить для этого поля нет. Это не то же самое, что быстрый переключатель языка в меню аватара, который меняет только то, что ты видишь в текущей сессии; это поле — твой постоянный язык по умолчанию, включая язык, на котором приходят письма-уведомления.

Принятие приглашения ​

Открой ссылку из письма-приглашения (/invitations/[token]). Что произойдёт дальше, зависит от того, вошла ли ты и под каким аккаунтом — система сама определяет это по адресу приглашения, а не спрашивает тебя.

Уже вошла под приглашённым адресом — один клик по Accept invitation, и готово.

Вошла под другим аккаунтом — страница не даст принять приглашение: покажет, кому оно отправлено, и предложит Sign out. После выхода открой ссылку из приглашения заново, чтобы принять его.

Не вошла, и на этот email уже есть аккаунт P4P — нажми Accept invitation, затем войди прямо там — по паролю, по magic link или по коду, как удобнее, без перехода на отдельную страницу входа. Если этот аккаунт был создан только через Google (без пароля), вместо поля пароля появится кнопка для ссылки сброса пароля — после того как задашь новый пароль, открой ссылку приглашения ещё раз, чтобы завершить.

Не вошла, и аккаунта с этим email ещё нет — короткая форма (имя + пароль) создаёт аккаунт и сразу присоединяет к организации. Если предположение оказалось неверным (адрес на самом деле уже зарегистрирован), ссылка переключит на форму входа.

Вход через Google доступен и там, и там — после него ты вернёшься на эту же ссылку-приглашение, уже авторизованной.

Создание организации ​

Кнопки для этого пока нет. Это происходит автоматически при регистрации — создаётся твоё личное рабочее пространство — подробнее см. Создание организации ниже.

Главная рабочего пространства ​

Понять, кто ты в этом пространстве — /org/[slug]/dashboard, страница, которая открывается по карточке пространства. Под названием она печатает Ваша роль: … — роли, которые ты там держишь, прямо из твоего членства.

Перейти в Участников, Роли или Продукты — три карточки, у каждой свои живые счётчики (всего участников и сколько из них Без роли; всего ролей и сколько кастомных; всего продуктов и сколько активных). Карточка, на которую не хватает прав, остаётся видимой, но блёклой и некликабельной — смысл в том, чтобы было видно, что раздел существует, а не гадать об этом.

Прочитать счётчик «—» — эта метрика не загрузилась или у тебя нет прав на этот раздел; настоящую ошибку покажет сама страница раздела. … означает, что она ещё грузится.

Настройки организации ​

Изменить название/описание/сайт организации — «Настройки» → отредактируй поля → Сохранить изменения. См. гайд по полям.

Задать язык организации по умолчанию — «Настройки» → Язык по умолчанию → выбери из списка. Сохраняется сразу. Менять может только OWNER — это язык, который получает каждый участник без собственного личного выбора, включая письма-уведомления.

Изменить настройки организации — гайд по полям ​

Без прав на управление организацией вся эта страница показывает те же три значения просто как текст — редактируемых полей нет вовсе.

  • URL пространства — показан для справки; задаётся один раз при создании и потом не меняется.
  • Название — обязательно, от 2 до 100 символов.
  • Описание — необязательно, до 2000 символов.
  • Сайт — необязательно; если что-то вписать, это должен быть полный URL (например, https://example.com).
  • Две вещи, которые здесь можно ожидать, но не найти: смена владельца (это Передать владение на списке Участники ниже, только для OWNER) и архивирование организации (пока не доступно в интерфейсе).

Участники ​

Пригласить кого-то в свою организацию — «Участники» → Пригласить участника → впиши email, выбери Роль → Отправить приглашение. См. гайд по полям.

Удалить участника или передать владение — список «Участники» → меню действий строки → Передать владение (может только текущий OWNER) или удаление.

Открыть страницу участника или его аккаунт — в списке «Участники» нажми на имя, чтобы открыть страницу участника в этом пространстве (роли, должность). Нажми на email под ним, чтобы открыть страницу аккаунта человека (Администрирование платформы → Пользователи); эта ссылка есть, только если у тебя есть право platform:users:read, а в твоей собственной строке она открывает твой профиль. Везде, где показан человек, имя и email работают одинаково.

Назначить участнику роль или изменить его должность — открой страницу этого участника → Роли или Должность в этом рабочем пространстве → Сохранить. См. гайд по полям.

Пригласить участника — гайд по полям ​
  • Email — некорректный адрес, или уже занятый участником/приглашением, ловится только при отправке.
  • Роль — обязательно; по умолчанию MEMBER, так что поле никогда не остаётся пустым. OWNER здесь никогда не предлагается — единственный способ сделать кого-то владельцем — Передать владение.
  • Отправлять приглашения можно до 3 раз в минуту.
Роли и должность участника — гайд по полям ​
  • Должность в этом рабочем пространстве — необязательно, до 100 символов. Свою всегда можешь редактировать сама; для чужой нужны отдельные права. Она привязана именно к этому рабочему пространству — в разных пространствах у одного человека может быть разная должность.
  • Роли — отметь любое количество, включая ноль. OWNER тоже никогда не появляется в этом списке — она перемещается только через Передать владение.
  • Кнопка Сохранить для каждой панели активируется только когда в ней что-то реально изменилось.

Роли ​

Создать свою роль для организации — «Роли» → Создать роль → назови, выбери права. См. гайд по полям.

Отредактировать, дублировать или удалить роль — меню ⋯ роли → Редактировать, Дублировать или Удалить. Три встроенные роли (Owner/Admin/Member) нельзя переименовать или удалить, поэтому Редактировать для них не предлагается — но Дублировать и Просмотр прав по-прежнему работают.

Создать/отредактировать роль организации — гайд по полям ​

Отдельная страница, не диалог — так у списка прав есть место развернуться.

  • Название — обязательно, до 100 символов, минимум 2. Должно быть уникальным только внутри твоей организации — у другой организации может быть роль с точно таким же названием.
  • Описание — необязательно, до 2000 символов.
  • Права — поле поиска или разворачивай по одной группе (Members, Roles, Organization, AI, Files и так далее). Роль с нулём отмеченных прав допустима. У каждой роли, включая три встроенные, есть Просмотр прав — быстрый взгляд только для чтения, без открытия полного редактора.
  • Сохранить/Создать активируется только когда что-то реально изменилось.
  • Удалить — роль, которая всё ещё назначена кому-то (активному участнику или ожидающему приглашению), удалить нельзя — сначала переназначь их на другую роль.

Продукты ​

Посмотреть, что есть у пространства — /org/[slug]/products. Карточка есть у каждого продукта, который P4P сейчас предлагает; значок на ней — статус этого пространства по этому продукту: Активна, Пробный период, Приостановлена, Истекла, Отменена или Нет подписки.

Подписаться на продукт — не отсюда. Эта страница только для чтения, в том числе для владельца; подписки выдаются из Администрирования платформы → Продукты и подписки. Обратись к администратору платформы.

Общие документы ​

Загрузить общий документ — «Общие документы» → вкладка Документы → Загрузить документ.

Посмотреть общий документ перед скачиванием — меню ⋯ строки → Просмотреть: открывает файл в окне просмотра с кнопкой Скачать, а не в голой вкладке браузера. Картинки, PDF, аудио и видео показываются как есть; .md — с разметкой, .csv — таблицей, .txt/.log — простым текстом. У форматов Office (.docx / .xlsx / .pptx и старых .doc / .xls / .ppt) просмотра нет — в меню только Скачать. Текст/CSV/Markdown больше 2 МБ или видео, которое браузер не может декодировать (иногда .mov), тоже предлагают только Скачать.

Удалить или восстановить общий документ — меню ⋯ строки → Удалить (перемещает в Корзину, хранится 30 дней); из вкладки Корзина — Восстановить или Удалить навсегда.

Подключённые приложения ​

Подключить AI-клиент к своему аккаунту P4P — начинается это не здесь, а в самом клиенте, и он спросит единственное, что можно взять только у P4P: адрес сервера. Он показан с кнопкой копирования на странице Подключённые приложения (https://api.<домен P4P>/mcp/external — страница показывает адрес того окружения, в котором ты находишься).

В ChatGPT сначала включи Настройки → Безопасность и вход → Режим разработчика: без него свои коннекторы не показываются вовсе. Рядом он помечен как «повышенный риск» — это общее предупреждение про любые сторонние коннекторы, а не про P4P. Дальше Настройки → Плагины → Просмотреть плагины → кнопка «+» справа от поиска → вставь адрес, аутентификацию оставь OAuth, создай. Нужен Plus или Pro — на бесплатном аккаунте свои коннекторы недоступны. В Claude Desktop проще: Settings → Connectors, добавить свой с тем же адресом, включать заранее ничего не надо.

Дальше клиент приведёт тебя на экран Авторизовать, где перечислено ровно то, что он просит. Разрешить возвращает тебя в клиент уже подключённым, Отклонить — без выданных прав.

Посмотреть, что подключено — /account/api-tokens (Подключённые приложения в меню аккаунта): по строке на приложение, с его правами, датой подключения и датой последнего использования. Не использовался означает буквально это.

Посмотреть, что приложение делало — стрелка в его строке. Каждый вызов инструмента записывается на его собственный токен, поэтому здесь видно: когда, какой инструмент и почему вызов не удался. Это и есть ответ на вопрос «что ChatGPT делал с моим аккаунтом», которого «последнее использование» никогда не давало.

Отрезать приложение — значок корзины в строке → Отозвать. Оно немедленно перестаёт работать, отменить это нельзя; чтобы подключить заново, придётся снова пройти авторизацию.

Понимать, что именно разрешаешь — приложение никогда не получит больше, чем есть у тебя самого. На экране авторизации под каждым запрошенным правом написано человеческим языком, что оно значит.

Уведомления ​

Безопасность аккаунта — единственная группа, которую нельзя выключить, и единственная, где каждое событие остаётся отдельной записью, а не сливается с предыдущей: два таких события подряд — ровно тот случай, когда важнее всего увидеть оба. Полное объяснение в Уведомлениях.

Аккаунт ​

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

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

Администратор зашёл в ваш аккаунт — вам сообщают, с указанной им причиной. Работа под чужой учётной записью и так фиксируется в журнале аудита, но журнал — это то, что нужно пойти и прочитать; уведомление делает это видимым тому, с кем произошло.

Аутентификация (/auth/*) ​

Простыми словами: это всё, что находится под экраном «Sign In» / «Sign Up» — создание аккаунта, вход пятью разными способами (пароль, ссылка на email, одноразовый код на email, Google, или сброс забытого пароля), и выход. Техническую часть ниже можно не читать, если ты не дебажишь один из этих сценариев.

Все эти роуты помечены @Public() в auth.controller.ts (сессия не нужна, чтобы до них достучаться), а «тяжёлые на запись» (register, login, magic-link, password-reset/request, otp/send) ещё несут @UseGuards(ThrottlerGuard) — если скриптуешь против них многократно, жди рейт-лимит (см. docs/TESTING.md, прежде чем вообще что-то скриптовать).

  • Sign up / Sign in (/auth/[mode].vue, mode = sign-in | sign-up) — одна страница, два режима, переключаются параметром роута. Sign-up бьёт в POST /auth/register, который создаёт User + PERSONAL организацию для него (см. Что такое P4P — у каждого пользователя есть личный рабочий кабинет) и отправляет письмо с подтверждением через Mailpit в dev. Sign-in бьёт в POST /auth/login, защищён LocalAuthGuard (email+пароль).
  • Verify email (/auth/verify-email) — принимает токен из письма с подтверждением (POST /auth/verify-email). POST /auth/resend-verification стоит за действием «отправить ещё раз», если ссылка истекла.
  • Forgot / Reset password (/auth/forgot-password, /auth/reset-password) — POST /auth/password-reset/request (всегда отвечает успехом, независимо от того, существует ли email — не считай ответ «проверь почту» подтверждением существования аккаунта), затем POST /auth/password-reset/confirm с токеном из письма.
  • Magic link (/auth/magic-link) — вход без пароля. POST /auth/magic-link отправляет письмо, POST /auth/magic-link/verify принимает токен и начинает сессию — та же форма токена, что и при входе по паролю.
  • Email OTP — POST /auth/otp/send / POST /auth/otp/verify. Отдельной страницы нет — доступ через переключатель («Войти по коду вместо пароля») прямо на /auth/sign-in (состояние signInMethod в LoginForm.vue), рядом с таким же переключателем для Magic Link.
  • Google OAuth (/auth/callback, /auth/error) — GET /auth/google запускает OAuth-рукопожатие (GoogleAuthGuard), GET /auth/google/callback завершает его и перенаправляет на /auth/callback (успех) или /auth/error (неудача — истёкшее/отклонённое согласие, конфликт аккаунтов и т.п.).
  • Logout (/auth/logout) — POST /auth/logout отзывает refresh-токен; access-токены короткоживущие (15 мин), так что серверный отзыв для них не нужен.
  • Session refresh — POST /auth/refresh — не страница, вызывается автоматически HTTP-клиентом портала, когда истекает access-токен. Refresh-токены непрозрачные, живут 30 дней, ротируются, с обнаружением кражи (повторное использование уже ротированного токена отзывает всю цепочку) — см. docs/iam/ADR-001-authentication.md.

Главная (/dashboard) ​

Простыми словами: место, куда попадаешь после входа. На ней две вещи: доступные тебе ярлыки (Администрирование платформы, Управление командой или карточка Профиль в резерве для подрядчика) и Мои рабочие пространства — список всех организаций, в которых ты состоишь, с полем поиска, когда список становится длинным.

Это намеренно не сводная страница. Счётчики, активность и графики живут внутри самих разделов; задача этой страницы — завести тебя в нужный, поэтому карточка и есть всё взаимодействие.

  • Администрирование платформы проверяет именно platform:admin:access, а не «есть хоть какая-то платформенная роль» (ужесточено 2026-08-14) — иначе роль, выданная только под Staffing, видела и могла открыть всю платформенную панель управления.
  • Управление командой проверяет workspace:staffing:read. Внутри /team нет ни одной страницы без прав, на которую можно было бы приземлиться, поэтому карточка прячется, а не показывается «чтобы отбросить назад».
  • Профиль в резерве появляется только у человека со связанной анкетой в кадровом резерве, у которого при этом нет доступа к команде — с доступом полная страница кадрового резерва и так покрывает всё, что показывает этот урезанный самопросмотр.
  • Список пространств приходит из GET /me/organizations, поэтому несёт с собой твоё членство и роли — роль под названием не требует второго запроса. P4P Internal отфильтровано из списка, если у тебя там нет workspace:organization:manage: это техническая якорная организация, членом которой является каждый сотрудник, и для остальных её карточка только упёрлась бы в workspace-access.ts на входе.
  • Кнопки «создать рабочее пространство» здесь нет. POST /organizations не требует особых прав и охотно создал бы его, но точки входа в портале не сделано — см. Создание организации.

Личный кабинет (/account/profile) ​

Страница профиля уровня User — отображаемое имя, аватар (POST/DELETE /me/avatar) и другие поля на GET/PATCH /me. Не привязана ни к одной организации; см. Архитектурные уровни.

Если у тебя есть хоть одна платформенная роль, страница также показывает панель Effective permissions (действующие права, 2026-08-14) — реальное объединение всех PlatformPermission, которые дают твои текущие платформенные роли, сгруппированное по областям в том же свёрнутом по умолчанию аккордеоне, что и собственный выбор прав в редакторе ролей (см. Роли выше). Читается из собственного ответа GET /me, всегда отражает реально сохранённые роли (не живой предпросмотр несохранённых изменений — такой вариант живёт на стороне админки, см. Администрирование платформы → Пользователи), и полностью скрыта, если у тебя вообще нет ни одной платформенной роли.

Язык (2026-08-21) — поле locale из PATCH /me, стоящее за User.locale. На фронтенде разделено на два composable: useLocaleSwitch (только на сессию, переключатель в меню аватара — никогда не сохраняет) и usePersistedLocale (сохраняет и переключает — используется только этим полем). Разделение появилось потому, что раньше это был один и тот же код: выбор «просто посмотреть» в меню аватара незаметно становился постоянным языком писем-уведомлений человека, так как меню аватара писало прямо в User.locale. resolveEmailLocale() (notifications/emails/messages.ts) — это то, что реально читает его: язык письма-уведомления — это собственный User.locale получателя, если он задан, иначе язык по умолчанию для организации, иначе en. Платформенный SUPER_ADMIN может переопределить язык по умолчанию любой организации из Администрирования платформы → Организации; ни одно из этих переопределений не затрагивает собственный User.locale конкретного участника — он всегда побеждает первым.

Принятие приглашения (/invitations/[token]) ​

Простыми словами: страница, на которую коллега попадает, кликнув по ссылке в письме-приглашении. Работает независимо от того, есть ли у него уже аккаунт P4P — если нет, автоматически появляется форма email/пароль.

Публичная страница (GET /organizations/invitations/:token, POST /organizations/invitations/:token/accept) — работает и для уже вошедшего пользователя, присоединяющегося к другой организации, и для совсем нового человека, который вводит email/пароль, чтобы создать аккаунт и присоединиться за один шаг. Приглашения несут набор Role (InvitationRole), назначаемых при принятии через MembershipRole.

GET .../:token возвращает accountExists/hasPassword вместе с приглашённым email — страница использует именно их, а не догадку, чтобы сразу выбрать нужную ветку (форма регистрации / вход / объяснение про отсутствие пароля) при первом рендере; каждая ветка остаётся переключаемой через ссылку на странице на случай, если выбор оказался неверным. Приглашение адресовано конкретному email, поэтому isWrongAccount (profile.email !== invitation.email при уже выполненном входе) полностью блокирует UI принятия — вместо того чтобы тихо присоединить не того пользователя или упасть с непонятной ошибкой "уже участник". Единственный выход — logout(), который ведёт на /, а не обратно на эту страницу (ссылку нужно открыть заново). Ветка входа для существующего аккаунта встроена прямо в страницу (пароль, magic link или OTP — те же три метода useAuth(), что и на /auth/sign-in), а не редирект на /auth/sign-in и обратно, поскольку email уже известен; аккаунт без пароля (зарегистрирован только через Google) получает вместо поля пароля кнопку сброса пароля — но этот путь не завершается за один шаг, сброс не логинит сам по себе, нужно вернуться по этой же ссылке позже. Живое e2e-покрытие: scripts/e2e/menu/invitation-accept.mjs.

Создание организации ​

POST /organizations требует только аутентифицированного пользователя — без особого права. Любой вошедший пользователь может создать дополнительные организации сверх своей автоматически созданной личной (например, чтобы представить компанию, которую он основывает). Подтверждено (2026-08-04, поиск по apps/portal по обоим API-путям): в интерфейсе портала для этого сегодня вообще нет UI — ни страницы /org/create, ни диалога «новая организация», доступного с /dashboard. Единственный способ дойти до POST /organizations из UI сейчас — косвенный: как побочный эффект регистрации (которая создаёт автоматическую PERSONAL организацию) или из диалога «New workspace» в Администрировании платформы (доступно только там). Самостоятельный сценарий «создать ещё одну организацию» готов на бэкенде, но пока не подключён к фронтенду.

Главная рабочего пространства (/org/[slug]/dashboard) ​

Простыми словами: первая страница внутри рабочего пространства — его название, твоя роль в нём и три карточки (Участники, Роли, Продукты) с живыми счётчиками, каждая ведёт в свой раздел.

Счётчики читаются из тех же списочных эндпоинтов, которые вызывают сами разделы (/organizations/:id/members, /roles, /subscriptions), параллельно и независимо: ошибка или нехватка прав дают — у конкретной метрики, а не блокируют страницу.

Одно намеренное отличие от хаба Администрирования платформы: там карточка без прав скрыта, здесь — показана блёклой и некликабельной. Логика в том, что три раздела пространства — маленький фиксированный набор, о существовании которого все и так знают: спрятать Роли от участника значит сделать вид, что ролей в пространстве нет. Список карточек платформенного хаба длиннее, и там показ бесполезных пунктов был бы шумом.

У Продуктов вообще нет права — что подключено пространству, видит любой его участник.

Настройки организации (/org/[slug]/settings) ​

Простыми словами: на странице Settings внутри организации две карточки — одна для названия, описания и сайта, вторая для языка по умолчанию. Две вещи, которые ты могла бы ожидать здесь найти — «передать владение кому-то другому» и «заархивировать/восстановить эту организацию» — либо живут в другом месте, либо пока не существуют в интерфейсе (подробности ниже).

  • Поля профиля (название, описание, сайт) — PATCH /organizations/:id, закрыто правом workspace:organization:manage, делегируемо ADMIN или кастомной роли. Это и карточка языка ниже — всё, что предоставляет settings.vue; у неё нет элементов управления архивацией/активацией.
  • Язык по умолчанию (PATCH /organizations/:id/default-locale, добавлено 2026-08-18) — только для OWNER, проверяется assertActorIsOwner() в сервисе, а не строкой Permission — так же, как Передача владения ниже; не делегируется через workspace:organization:manage, поэтому фронтенд-проверка повторяет собственную проверку isOwner из members/index.vue, а не usePermissions(). Любой другой участник без собственного личного выбора языка получает то, что задано здесь (или en, если здесь ничего не задано) — полный порядок приоритета см. в Язык выше. Платформенный SUPER_ADMIN также может переопределить это снаружи организации — см. Администрирование платформы → Организации.
  • Передача владения — несмотря на то, что это действие уровня жизненного цикла организации, его нет на странице Settings. Это действие по строке на странице Участники (workspace.members.actions.transferOwnership, members/index.vue) — «сделать этого участника владельцем вместо меня». PATCH /organizations/:id/transfer-ownership, закрыто только TenantGuard на уровне роута, с реальной проверкой (assertActorIsOwner()) внутри organizations.service.ts — не делегируется через workspace:organization:manage; нужно реально занимать роль OWNER.
  • Архивация / активация (POST /organizations/:id/archive, PATCH /organizations/:id/activate) — подтверждено (2026-08-04, поиск по apps/portal по обоим путям): точки входа в интерфейсе нет нигде. Оба роута работают (то же ограничение только TenantGuard + assertActorIsOwner(), что и у передачи владения) и доступны прямым API-вызовом, но сейчас в портале нет кнопки/диалога, который бы их вызывал — OWNER не может самостоятельно заархивировать свою организацию через UI сегодня. Единственный UI портала, близкий к архивации — это действие restore в Администрирование платформы → Организации (обратное направление, доступно только там).
  • Slug неизменяем и не имеет пути редактирования (та же логика, что и у User.email).

Участники (/org/[slug]/members, /org/[slug]/members/[userId]) ​

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

  • Список (GET /organizations/:id/members), с выпадающим меню «Действия» по строке (workspace:members:read для самого списка; видно любой системной роли — вплоть до базовой MEMBER).
  • Приглашение (POST /organizations/:id/members/invite) — workspace:invitations:manage. Throttled (ThrottlerGuard) в дополнение к TenantGuard/PermissionsGuard.
  • Удаление участника и передача владения — оба живут на странице списка (members/index.vue), как действия по строке в выпадающем меню, со своими диалогами подтверждения. Удаление требует workspace:members:remove; передача владения — только для OWNER (см. Настройки организации выше — это действие жизненного цикла, хоть и живёт на этой странице, а не на Settings).
  • Назначение/снятие роли — на странице карточки участника (members/[userId].vue), не на списке. POST/DELETE .../members/:userId/roles/:roleId требуют workspace:members:assign_role — отделено от редактирования job title (2026-08-14, раньше было одно объединённое portal:members:manage), поскольку кастомная для организации роль вполне может захотеть делегировать «может переименовывать должность» без одновременной выдачи «может менять, что человеку разрешено».
  • Изменение job title — та же страница карточки. PATCH .../members/:userId/job-title требует workspace:members:edit; изменение своего собственного job title (PATCH .../members/me/job-title) требует только TenantGuard — повышенное право не нужно, чтобы поменять свой отображаемый титул.
  • Выход из организации (DELETE /organizations/:id/leave) — подтверждено (2026-08-04, поиск по apps/portal): точки входа в интерфейсе нет. Роут на бэкенде работает (только TenantGuard, любой может убрать сам себя, заблокировано для единственного/последнего OWNER согласно docs/iam/ADR-007-security-invariants.md), но кнопки «Выйти из организации» сегодня нет нигде в портале. Не путать с SetOnLeaveDialog.vue — это несвязанная фича Персонал (отметить человека в отпуске), а не выход из организации.

Роли (/org/[slug]/roles) ​

Простыми словами: роли — это именованные наборы прав (например, «может приглашать людей» или «может редактировать название организации»), которые назначаются участникам. Три роли встроены и не могут быть переименованы или удалены — Owner, Admin, Member — и поверх них можно создавать свои. (Четвёртая, Viewer, существовала до 2026-08-19 — см. ниже.)

roles.controller.ts, смонтирован на organizations/:id/roles. Каждый роут требует @UseGuards(TenantGuard, PermissionsGuard) именно в этом порядке — см. заметку в CLAUDE.md/docs/iam/PERMISSIONS_CATALOG.md о том, почему PermissionsGuard никогда не может быть зарегистрирован глобально.

  • Список ролей + список назначаемого каталога прав (GET /organizations/:id/roles, GET .../roles/permissions) — workspace:roles:read.
  • Создание кастомной роли (POST) — workspace:roles:create. Редактирование названия/описания роли и добавление/удаление права у роли (PATCH :roleId, POST/DELETE :roleId/permissions/:permissionId) — workspace:roles:edit. Удаление (DELETE :roleId) — workspace:roles:delete. Разделено на три права 2026-08-14 (раньше было одно объединённое portal:roles:manage), чтобы роль могла, например, делегировать «создавать и настраивать роли» без одновременной выдачи «удалять их». Системные роли (OWNER/ADMIN/MEMBER) защищены от удаления/переименования триггером БД, а не только проверкой в приложении — см. docs/iam/ADR-003-rbac.md.

VIEWER упразднена, 2026-08-19. Она и MEMBER были задокументированы как намеренно идентичные в посеянном наборе прав с 2026-07-05 (обе только для чтения, без реального различия, которое стоило бы выдумывать) — files:upload/files:manage (добавленные к MEMBER позже) были единственным оставшимся отличием, и их убрали из MEMBER, вместо того чтобы держать ради этого две роли. Строки Role.isSystem: true защищены триггером БД от UPDATE/DELETE; отдельная миграция отключила этот триггер на время одного намеренного DELETE, затем включила обратно. Не «возвращай» четвёртую базовую роль, не прочитав полное обоснование в docs/iam/PERMISSIONS_CATALOG.md.

Продукты (/org/[slug]/products) ​

Простыми словами: каталог того, что предлагает P4P, со статусом подписки этого рабочего пространства на каждой карточке. Только для чтения — ничто на этой странице подписку не меняет.

Различие, которое страница изначально путала и которое стоит держать в голове: GET /products и так возвращает только те продукты, которые P4P сейчас предлагает (isActive), — то есть этот флаг ничего не говорит про это пространство. Значок берётся из GET /organizations/:id/subscriptions: ACTIVE/TRIAL читаются как «подписано», всё остальное (SUSPENDED, EXPIRED, CANCELLED и полное отсутствие подписки → Нет подписки) — нет. До исправления каждая карточка в каждом пространстве показывала Активна, потому что подписки не смотрел никто.

Подписки выдаёт и отзывает администратор платформы из Администрирования платформы → Продукты и подписки — никакого самостоятельного оформления в продукте пока нет вообще.

Общие документы (/team/shared-documents) ​

Простыми словами: общая библиотека файлов для сотрудников P4P. Недолго жила по адресу /org/p4p-internal/shared-documents (2026-08-20–2026-08-23) из соображения, что под капотом это всегда была общая платформенная возможность файлового хранилища, нацеленная на одну фиксированную организацию, никогда ничего специфичного для Персонал — но этот URL полностью подменял весь сайдбар Team Management на однопунктовый сайдбар оболочки организации, что воспринималось как «меня перекинуло куда-то ещё», а не «эта страница изменилась», и в повседневном использовании стоило дороже, чем архитектурная чистота маршрута. Перенесена обратно.

Использует общую платформенную возможность файлового хранилища (files:read/files:upload/files:manage) для одной фиксированной, синтетической организации (system-org-shared-documents, slug p4p-internal) — скрипт самой страницы жёстко использует этот id организации независимо от того, через какой маршрут на неё попали, поскольку у бэкенда нет понятия «общих документов» у какой-либо другой организации. Вкладки «Активные»/«Корзина», хранение 30 дней при мягком удалении.

Доступ закрыт через middleware/staffing-access.ts с requiresPermission: 'files:read' — тем же механизмом, что и любая другая страница /team/*, только проверяющим членство в фиксированной организации P4P Internal вместо параметра :slug. Доступ есть у всех, вплоть до MEMBER-уровня с files:read; загрузка/удаление требуют собственных files:upload/files:manage организации (см. Роли выше). Общему гейту workspace:organization:manage на остальной части /org/p4p-internal/* (Roles, Members — см. Участники) больше не нужно исключение для этой страницы — она через этот middleware больше не проходит вовсе.

Обычный способ, которым сотрудники P4P её находят: собственный сайдбар layouts/team.vue сохраняет пункт «Общие документы» (закрыт правом files:read, тем же реальным правом, что и сама страница). Собственная карточка раздела /team/dashboard и quick action «Загрузить документ» — второй путь туда, см. Персонал. Старый URL /org/p4p-internal/shared-documents по-прежнему работает — теперь он просто перенаправляет на /team/shared-documents, чтобы старая закладка или ссылка не вела в никуда. Ничего из этого не проходит через список «My Workspaces» на корневом /dashboard, который намеренно скрывает P4P Internal от всех, у кого нет workspace:organization:manage (собственный visibleOrganizations в dashboard.vue — показывать его всем остальным раньше читалось просто как мусор в списке рабочих пространств для людей, которые могли попасть только на одну узкую страницу внутри него).

Подключённые приложения (/account/api-tokens, /oauth/authorize) ​

Простыми словами: внешние ИИ-клиенты — ChatGPT, Claude Desktop, всё, что говорит по MCP, — которым ты разрешил обращаться к своему аккаунту P4P от твоего имени. Подключённые приложения в меню аккаунта — это список; /oauth/authorize — экран согласия, который проходится один раз на приложение.

Это обратное направление по отношению к MCP-странице модуля ИИ: там собственные агенты P4P ходят во внешние инструменты, здесь внешний клиент ходит внутрь P4P.

  • Что такое подключение на самом деле. Каждая строка — это PersonalAccessToken: хранится хешированным, как refresh-токен, и показывается клиенту ровно один раз, в момент выдачи. Колонка Префикс — единственное, что видно потом, и нужна только чтобы отличать строки друг от друга.
  • Где взять адрес. Сервер, к которому подключается клиент, — https://api.<домен>/mcp/external; он показан с кнопкой копирования на самой странице Подключённые приложения. Это единственное, что нельзя узнать из клиента, поэтому подключение и начинается с копирования оттуда.
  • Как оно появляется. Только через поток OAuth 2.1 (2026-08-06): клиент регистрируется сам (POST /oauth/register, RFC 7591, публичные клиенты только с PKCE — без client secret), отправляет тебя на /oauth/authorize и меняет полученный код на токен. Самостоятельное «создать токен» с этой страницы намеренно убрано; оно существовало до тех пор, пока коннекторы ChatGPT не показали, что сырой Bearer-токен принимает далеко не каждый клиент. Токены, созданные тем способом, продолжают работать, пока их не отозвать.
  • Потолок возможностей. Права токена всегда могут быть только подмножеством твоих собственных платформенных прав на момент подтверждения. Кроме того, права ограничивают инструменты строже, чем соответствующие HTTP-роуты: platform:staffing:read даёт только чтение, platform:staffing:manage добавляет создание задач, смену статусов и комментарии — весь смысл в том, чтобы выдать ИИ потолок ниже своего собственного.
  • Всё, что оно делает, логируется. Каждый вызов внешнего MCP-инструмента пишется в журнал аудита с привязкой к токену, а из ответов инструментов вырезаются личные контактные данные, прежде чем те дойдут до модели.
  • Отзыв срабатывает мгновенно и не отменяется; приложение придётся авторизовать заново с нуля.

Бэкенд: apps/core-api/src/oauth (сервер авторизации) и apps/core-api/src/mcp-external (сами инструменты), намеренно отделённые от внутреннего модуля src/mcp с его моделью доверия к сессионному JWT. Полное обоснование — в docs/mcp/ADR-001-external-mcp-server.md.

Impersonation ​

Простыми словами: позволяет SUPER_ADMIN временно увидеть платформу глазами другого пользователя — чтобы помочь отладить его аккаунт или воспроизвести проблему, о которой он сообщает. Каждое использование логируется. Impersonate на собственной странице пользователя в Администрировании платформы запускает её (2026-08-20 — см. ниже; до этой даты фронтенда действительно не было, только прямой API-вызов).

Только для SUPER_ADMIN (SuperAdminGuard — буквальная проверка роли, а не делегируемое право, поскольку impersonation намеренно не делегируется). POST /auth/impersonate начинает сессию от имени другого активного пользователя; POST /auth/impersonate/:sessionId/end её завершает. Каждый старт/финиш/отказ записывается в журнал аудита (impersonation.started/.ended/.rejected).

Фронтенд (добавлен 2026-08-20): кнопка Impersonate на собственной странице пользователя в Администрировании платформы → Пользователи (/platform/users/[id]) — скрыта для собственного аккаунта и для любой цели не в статусе ACTIVE, поскольку в обоих случаях войти не во что. Указание причины открывает сессию и переносит на /dashboard, откуда ты уже реально смотришь глазами этого пользователя (его настоящие данные, а не превью) — глобальный ImpersonationBanner (примонтирован один раз поверх всего приложения, переживает любую страницу и layout) называет, чьими глазами ты смотришь, и предлагает End impersonation, что возвращает на страницу пользователя, с которой всё началось. Токен impersonation намеренно только access, без refresh-токена (жёсткий TTL 1 час, как описано в модели токенов выше в этом модуле) — настоящие токены админа не перезаписываются, а откладываются в сторону, поэтому завершение сессии (или просто истечение токена) восстанавливает их, а не оставляет админа разлогиненным. Живое покрытие в браузере — testImpersonationUi в scripts/e2e/menu/impersonation.mjs. сырой аутентифицированный запрос.

Журнал аудита ​

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

Каждое событие в этом модуле (вход, смена роли, жизненный цикл организации, impersonation...) записывается в AuditLog, доступный только на добавление, через AuditService (никогда напрямую через Prisma — триггер БД блокирует update/delete). UI просмотра (/platform/audit, platform:audit:read) — это страница Администрирования платформы — см. Администрирование платформы → Журнал аудита.

Тестирование этого модуля ​

scripts/e2e/menu/auth.mjs, organizations.mjs и impersonation.mjs (41 проверка всего) прогоняют сценарии этого модуля от начала до конца и проставляют lastVerified выше. impersonation.mjs получил реальное покрытие в браузере 2026-08-21 (testImpersonationUi) — у потока кнопка/ диалог/баннер/завершение сессии, добавленного 2026-08-20, не было никакого покрытия, ни юнитового, ни e2e, до этого прогона; собственные API-проверки файла (старт/завершение сессии напрямую) не изменились. organizations.mjs также покрывает рабочий дашборд (/org/[slug]/dashboard, 2026-08-12) — раньше на него только заходили как на промежуточную точку по пути к другим вкладкам, никогда не проверяя его саму по себе: собственную строку роли зрителя, карточки-ярлыки Members/Roles/Products, и что разрешённая карточка реально переходит по клику. apps/portal/pages/org/[slug]/dashboard.test.ts (5 тестов) покрывает права доступа той же страницы — у Products вообще нет ограничения (видна каждому участнику каждого воркспейса), тогда как Members/Roles остаются серыми и некликабельными без собственного права на чтение. Не покрыто автоматизацией, стоит проверить руками — см. docs/MANUAL_TESTING.md: Google OAuth, переключение между двумя организациями, восстановление заархивированной организации, передача владения, принятие приглашения существующим пользователем (автоматизирован только сценарий с совсем новым пользователем), и редактирование набора прав у существующей роли (автоматизировано только назначение роли участнику).

LoginForm.vue/RegisterForm.vue/pages/auth/[mode].vue (раунд 51 инициативы тестового покрытия портала, 2026-08-12) — последнее крупное семейство страниц вообще без юнит-покрытия, хотя auth.mjs уже тщательно гоняет его живьём (см. выше). Сначала проверили существующее покрытие: каждая реальная ветка этих двух форм уже прогоняется вживую (регистрация → подтверждение → вход, неверный пароль, сброс пароля, magic link, email OTP) — единственное, что действительно нельзя автоматизировать в любом случае, это Google OAuth (реальный внешний экран согласия, тот же класс, что Turnstile у HR Pool), поэтому нового e2e не добавляли. Вместо этого — юнит-покрытие: apps/portal/components/auth/LoginForm.test.ts (12 тестов) — успешный вход по паролю с редиректом на /dashboard, ветка emailNotVerified со ссылкой повторной отправки, успех/рейт-лимит повторной отправки, invalidCredentials показывает общее сообщение (никогда не раскрывая текст бэкенда), любая другая ошибка — сообщение от сервера, успех/рейт-лимит отправки magic link, полный цикл OTP-отправка→проверка→редирект и его собственный провал, и что разделитель/кнопка Google рендерятся только в ветке с паролем. apps/portal/components/auth/RegisterForm.test.ts (6 тестов) — успешная регистрация со ссылкой повторной отправки, рейт-лимит регистрации, ошибка уже зарегистрированного email, и пути повторной отправки (успех/рейт-лимит/не-раскрывать-при-других- ошибках, тот же контракт «никогда не подтверждать и не опровергать существование аккаунта», что у requestMagicLink/requestOtp). apps/portal/pages/auth/[mode].test.ts (3 теста) — переключение invisible/inert между двумя формами и переход по вкладке на другой режим. При написании тестов повторной отправки найден реальный, небольшой UI-баг, не исправлен (код приложения, вне рамок этого раунда, тот же класс находки, что и гонка AlertDialogAction в других местах этой инициативы): LoginForm.vue's onResendVerification() никогда не сбрасывает собственный resent обратно в false в начале функции, только выставляет true при успехе — поскольку шаблон проверяет v-if="resent" раньше v-else-if="resendError", попытка повторной отправки с рейт-лимитом ПОСЛЕ более ранней успешной всё ещё показывает устаревшее сообщение «письмо отправлено» вместо сообщения о лимите. Малая реальная досягаемость (кнопка повторной отправки заблокирована весь кулдаун, так что нужно 3+ реальных отправки за минуту) — но настоящая нестыковка, зафиксирована собственным тестом, а не молча обойдена.

/auth/forgot-password, /auth/reset-password (раунд 52, 2026-08-12, 2-я часть семейства /auth/*) — auth.mjs's testPasswordReset() уже прогоняет полный живой цикл (запрос → реальная ссылка в письме → подтверждение → старый пароль отклонён → новый работает), так что этот раунд тоже только юнит. apps/portal/pages/auth/forgot-password.test.ts (5 тестов) — успешный запрос показывает состояние «отправлено» с рабочей ссылкой повторной отправки, рейт-лимит запроса, любая другая ошибка падает на сообщение от сервера, повторная отправка целится в ТОТ ЖЕ адрес, что и исходный запрос, и «Назад ко входу» переходит корректно. apps/portal/pages/auth/reset-password.test.ts (4 теста) — отсутствие ?token= показывает ошибку недействительной ссылки вместо любой формы, успешное подтверждение показывает состояние успеха со ссылкой на вход, недействительный/истёкший токен показывает собственное сообщение сервера, и ошибка без сообщения падает на общий текст о недействительной ссылке.

/auth/magic-link, /auth/verify-email, /auth/callback, /auth/error, /auth/logout (раунд 53, 2026-08-12) — закрывает всё семейство /auth/* целиком (раунды 51-53). auth.mjs's testMagicLink()/testRegisterVerifyLogin() уже прогоняют реальный happy path каждой страницы живьём; этот раунд покрывает ветки, которых эти однопроходные живые прогоны не достигают — отсутствующие/отклонённые токены, и защита от open-redirect в returnTo (utils/returnTo.ts's isSafeReturnTo, общая для magic-link.vue и callback.vue), которую ни живая проверка magic link, ни Google OAuth не проверяют ни в одну сторону. apps/portal/pages/auth/magic-link.test.ts (5 тестов) — отсутствующий токен, валидный токен со входом и редиректом на /dashboard, безопасный returnTo редиректит туда вместо этого, небезопасный (//evil.example.com) откатывается на /dashboard вместо перехода по нему, и отклонённый токен. apps/portal/pages/auth/verify-email.test.ts (4 теста) — отсутствующий токен, успех, собственное сообщение сервера об отказе, и общий фолбэк ошибки без сообщения. apps/portal/pages/auth/callback.test.ts (4 теста) — страница приземления Google OAuth: отсутствие accessToken/refreshToken ведёт на /auth/error вместо попытки входа, реальные токены логинят и редиректят на /dashboard, та же пара безопасный/небезопасный returnTo, что у magic-link. Сам Google OAuth остаётся единственной веткой всего семейства, действительно непроверяемой живьём (реальный внешний экран согласия, тот же класс, что Turnstile у HR Pool) — эта страница покрывает ровно то, что остаётся после вычитания этого. apps/portal/pages/auth/error.test.ts (1 тест) — статичная страница ошибки OAuth вообще без логики, просто smoke-тест рендера. apps/portal/pages/auth/logout.test.ts (2 теста) — useCurrentUser().reset() и финальный navigateTo('/') оба срабатывают при монтировании, включая случай, когда сам запрос логаута падает (ни то ни другое не видно живой проверке, которая смотрит только на успех сетевого запроса).

/account/profile (не имел покрытия вообще, ни юнит, ни живого, до 2026-08-12) — scripts/e2e/menu/account.mjs (4 проверки, раундовый подход, по одной странице /account/* за раз — notifications.vue/api-tokens.vue каждая свой будущий раунд) редактирует Отображаемое имя, сохраняет, подтверждает тост успеха, затем перезагружает страницу С НУЛЯ и читает поле обратно, чтобы подтвердить, что оно реально сохранилось (а не просто что сработал тост), и восстанавливает исходное значение тем же способом после, чтобы этот прогон не оставлял имя общего аккаунта e2e-test-admin изменённым между запусками. Сопутствующее юнит-покрытие, apps/portal/pages/account/profile.test.ts (9 тестов) — сброс формы к реальным значениям профиля при загрузке, незаданный часовой пояс по умолчанию берёт определённую зону против сохранения реальной уже установленной, построение payload у onSubmit (null, а не ''/undefined, для очищенного поля, и interestAreaOther только когда OTHER среди interestAreas), ошибка неудачного сохранения инлайн+тост, поставленное в очередь изменение аватара, вызывающее removeAvatar/uploadAvatar при сохранении (последнему понадобился собственный мок fetch — вторая страница в этой инициативе с ручной загрузкой файлов, наряду с XHR-версией Shared Documents), и опция часового пояса «Авто», разрешающаяся в реальную определённую зону. Первая страница этой инициативы, которой понадобился РЕАЛЬНЫЙ useCurrentUser() (а не мелкий общий тестовый фейк, которым пользуется каждая страница /team/*) — его updateProfile/uploadAvatar/removeAvatar/состояние loaded вообще отсутствуют в том фейке.

/account/api-tokens («Подключённые приложения» — только чтение/отзыв, самостоятельное создание убрали, когда его заменил OAuth 2.1, docs/mcp/ADR-001-external-mcp-server.md Решение 5) — account.mjs вырос до 6 проверок. Найден реальный баг при первом живом прогоне (2026-08-12, исправлен в тот же день, с одобрения пользователя): GET /me/personal-access-tokens выдавал 500 на этой dev БД — две миграции (add_personal_access_tokens, oauth_authorization_server) существовали в репозитории, но никогда реально не применялись здесь, так что таблиц PersonalAccessToken/OAuthClient/OAuthAuthorizationCode/OAuthRefreshToken реально не существовало. Вся функция OAuth 2.1 authorization server ни разу не запускалась против реальной dev БД до этого раунда — исправлено командой pnpm --filter @p4p/database migrate:dev. Файл теперь дымовым тестом проверяет, что страница загружает реальный 200 против теперь работающего эндпоинта; у e2e-test-admin пока нет реально подключённого OAuth-приложения, которое можно было бы вживую перечислить/отозвать, а создание такого вживую требует полного потока DCR + PKCE + страницы согласия (/oauth/authorize) — непропорциональная стоимость настройки для этого раунда, то же решение, что и пропуск живого самостоятельного создания в HR Pool в других местах этой инициативы. Сам поток отзыва покрыт юнит-тестами: apps/portal/pages/account/api-tokens.test.ts (7 тестов) — рендер реальных данных (предпочтение oauthClientName перед собственным name токена), «Никогда не использовался» против реальной даты, пустое состояние, ошибка загрузки, и успешный/неудачный отзыв.

/oauth/authorize (экран согласия OAuth 2.1, docs/mcp/ADR-001-external-mcp-server.md Решение 5) — позже всё же получил полный живой прогон, который раунд 40 назвал непропорциональным: scripts/e2e/menu/oauth-authorize.mjs (10 проверок, новый файл, 2026-08-12) — ПЕРВЫЙ прогон всего потока от начала до конца когда-либо, на любом окружении, которого касалась эта инициатива — динамическая регистрация клиента (RFC 7591, без этапа одобрения, без выдачи client secret), собственный экран согласия портала с сессионной аутентификацией (реальное имя клиента и запрошенный скоуп отображаются), одобрение и получение реального кода авторизации, обмен токена с проверкой PKCE (и подтверждение, что НЕПРАВИЛЬНЫЙ code_verifier реально отклоняется, а не просто принимается и игнорируется), появление получившегося токена как реального «Подключённого приложения» на /account/api-tokens — замыкая цикл с раундом 40 — и его отзыв там через тот же UI-поток, который раунд 40 уже проверил. Все 10 проверок прошли чисто с первого же живого прогона: функция реально работает, как только был исправлен пробел с миграцией (раунд 40). Один реальный нюанс e2e-скрипта найден и исправлен при написании этого: клик по «Одобрить» запускает реальный редирект window.location.href в тот же момент, когда приходит ответ, что делает недействительным дескриптор Chrome DevTools Protocol на этот же ответ до того, как обычный waitForResponse(...).json() успевает его прочитать («No resource with given identifier found») — исправлено перехватом пары запрос/ответ через page.route(), который читает тело ДО того, как собственный JS страницы вообще его увидит, и отдельно полностью блокируя реальный внешний редирект (этого фальшивого домена клиента не существует), так что страница никогда реально не пытается уйти. Сопутствующее юнит-покрытие, apps/portal/pages/oauth/authorize.test.ts (9 тестов) — первый раунд в этой инициативе без фейка прав в стиле team-stubs.ts/org-stubs.ts (этой странице нужен только голый useAuth().isAuthenticated, свой небольшой локальный мок): редирект на страницу входа с returnTo, когда не авторизован (даже не доходя до вызова consent-info), ошибка неверного запроса при отсутствующем обязательном параметре запроса, реальная информация о согласии с рендером подсказок к скоупам (немаппированный скоуп откатывается к сырой строке скоупа), состояние «скоупы не запрошены», ошибка загрузки, decide(true), отправляющий полный PKCE payload и перенаправляющий на реальный callback через window.location.href (понадобился собственный мок Object.defineProperty, та же техника, что использовал mcp/index.test.ts раунда 46 — второй тест этой инициативы, которому понадобилось мокировать реальную навигацию браузера), decide(false), отправляющий approved: false, и неудачный approve/deny, сбрасывающий флаг deciding.