panel: shared nav, account settings, backup page, connection settings

Phase 12 (UI/UX). The navigation bar now renders once from layout.html
instead of being copied into each content template, so it is present on
every authenticated page — including the domain page and its delete
confirmation, which had no links at all — and the current page is
highlighted via .Active rather than quietly dropping out of the list.

New /account page changes the administrator's username and/or password:
the current password is required and the attempt is throttled on the same
limiter as the login form, so this route cannot be used to brute-force
past that limit. A password change invalidates every other session while
keeping the one performing it; a rename carries that session over.

Backup and domain import move from a card in the middle of the domain
list to their own /backup page, one card each; the handlers themselves
are unchanged, only the page the import form renders its errors on.

The domain page gains a "Sending server settings" card (server, port,
encryption) so a client can be configured without reading the docs; 587
is listed only when SUBMISSION_ENABLE is true for this deployment, which
is a deploy-time flag the panel cannot verify at runtime.

Client-side (static/panel.js, no libraries): Copy buttons on the values
that get carried elsewhere (DKIM record, new application credentials,
server name), and the Addresses field is hidden while the address mode is
wildcard, where the server ignores it.

Verified in a container on the dev server: setup, login, every page's
nav and active item, domain and application creation, all account-form
paths including cross-session invalidation, import errors, full backup
download. gofmt/vet/test/docker build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:34:59 +03:00
parent 1f29bef87a
commit 147072dbb9
28 changed files with 720 additions and 282 deletions
+8 -118
View File
@@ -7,6 +7,10 @@
Ниже остаётся только то, что **ещё не сделано**: открытые вопросы для
согласования и опциональная линия 2.x.x.
**Фаза 12 (UI/UX: общий nav-partial, `/account`, `/backup`, параметры
подключения, кнопки Copy, скрытие поля адресов) выполнена** — детали в
[CHANGELOG.md](../CHANGELOG.md), здесь не повторяется.
**Основа:** [specification.md](specification.md) v1.0.
---
@@ -27,7 +31,7 @@
3. **CSRF — только `SameSite=Lax`, без токенов.** Достаточно для современных браузеров (все мутации — POST, все GET read-only), но не защищает при downgrade до старого браузера/особых прокси и не даёт защиты на уровне «per-request». **Вопрос:** считать `SameSite=Lax` достаточным для single-admin панели (моя рекомендация — да) или добавить double-submit CSRF-токен.
4. **Cookie без префикса `__Host-`.** Сейчас `selfpost_session` (`Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/`). Префикс `__Host-` дал бы браузерный гарант «только HTTPS, только этот хост, без Domain». Мелочь, но бесплатная. **Вопрос:** переименовать (учесть dev-режим `PANEL_COOKIE_SECURE=false``__Host-` требует `Secure`, т.е. только когда secure включён).
5. **Setup-ссылка печатается в stdout контейнера.** По ТЗ (7.6.1) — так и задумано, но если логи контейнера уезжают в агрегатор, токен там осядет на 10 минут. Файл `/data/setup-token` (0600) — альтернатива, уже реализована в коде ([internal/web/setup.go](../internal/web/setup.go) `announce`/token file). **Решено:** базовый вариант объявления — stdout (по ТЗ), код не меняется. Остаётся только осветить это в пользовательской документации и указать на уже существующий файл `/data/setup-token` как более защищённую альтернативу для тех, у кого логи контейнера уезжают в централизованный агрегатор. **Реализация — см. Фазу 14.B.**
6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено:** добавить раздел настроек аккаунта (логин + пароль) — см. Фазу 12 ниже. 2FA и мульти-админ остаются явно 2.x/вне объёма.
6. **Нет 2FA / смены пароля админа / нескольких админов из UI.** ТЗ этого не требует (один админ, secret-link). Смена пароля сейчас — только пересоздание состояния. **Решено и реализовано (Фаза 12):** добавлен раздел настроек аккаунта `/account` (логин + пароль, с проверкой текущего пароля и инвалидацией остальных сессий). 2FA и мульти-админ остаются явно 2.x/вне объёма.
### B. Надёжность и эксплуатация
@@ -46,120 +50,6 @@
---
## Фаза 12 (v1.x) — UI/UX: навигация, аккаунт, backup и параметры подключения
**Статус:** запланирована, не начата.
**Цель:** устранить конкретные недостатки панели, замеченные при использовании: (1) навигационная шапка не показывает, на какой странице находится админ; (2) нет способа сменить логин/пароль администратора после `/setup`, кроме пересоздания состояния; (3) полный бэкап и импорт домена свалены в один блок на дашборде, хотя это два разных по смыслу и риску действия; (4) страница домена не показывает сервер/порт/шифрование, нужные для настройки клиента отправки; (5) значения, которые нужно переносить во внешние сервисы (DKIM-запись, пароль приложения), нельзя скопировать одной кнопкой; (6) поле «Addresses» показывается даже в режиме, где оно не используется.
### A. Навигационная шапка обязательна на каждой странице панели
**Правило (закладывается на будущее, не только для текущих страниц):** навигационная шапка —
обязательный элемент **каждой** аутентифицированной страницы панели, без исключений. Чтобы это не
превращалось в чек-лист «не забыть добавить нав в очередной новый шаблон» (как уже случилось с
`domain_detail.html`/`domain_delete.html`, см. ниже, и как иначе случилось бы с будущими `/account`,
`/backup`, `/status` из этой же и следующей фазы) — нав должен рендериться **структурно**, из
[layout.html](../internal/web/templates/layout.html) (общей обёртки всех страниц), а не копипастой
в каждом content-шаблоне. Тогда гарантия «шапка есть везде» не зависит от того, вспомнил ли автор
конкретной страницы её вставить.
Сейчас же топбар (`Domains`/`Send log`/`Queue`/`Log`) продублирован вручную и по-разному в разных
content-шаблонах (каждый определяет `{{define "content"}}` независимо, `layout.html` их просто
оборачивает, см. [layout.html:81](../internal/web/templates/layout.html)):
- [dashboard.html](../internal/web/templates/dashboard.html), [queue.html](../internal/web/templates/queue.html), [sendlog.html](../internal/web/templates/sendlog.html), [logtail.html](../internal/web/templates/logtail.html) содержат нав-ссылки, но без выделения текущей страницы, и текущая страница обычно **не включает ссылку на саму себя** (например, `queue.html` не содержит пункта «Queue»), из-за чего создаётся впечатление, что кнопка «пропадает», а не выделяется;
- [domain_detail.html](../internal/web/templates/domain_detail.html) и [domain_delete.html](../internal/web/templates/domain_delete.html) — **самая посещаемая страница панели** (там живут DKIM-запись, приложения, лимиты) — вообще **не содержат нав-ссылок**: топбар там ограничен `{{.User}}`/«Sign out», и чтобы перейти на Send log/Queue/Log, нужно сначала вернуться на «← All domains». Именно это отсутствие и стало поводом сформулировать правило выше как общее, а не как точечный фикс двух файлов.
**Реализация:**
- Вынести `{{define "nav"}}` и вызвать его **из `layout.html`**, один раз, до `{{template "content" .}}` — не из отдельных content-шаблонов. Заодно туда же уходит и `{{.User}}`/«Sign out», которые сейчас тоже продублированы в топбаре каждого шаблона — так они тоже перестают зависеть от того, вспомнили их вставить или нет.
- Content-шаблоны сохраняют только то, что специфично для страницы (заголовок `<h1>`, специфичные действия вроде будущего «Reload» на `/status`) — без списка нав-ссылок и без `{{.User}}`/Sign out.
- Добавить в данные каждого шаблона поле `Active string` (`"domains"`/`"queue"`/`"sendlog"`/`"logtail"`, позже — `"status"`/`"account"`/`"backup"`) — простая правка в `handleDashboard`, `handleQueue`, `handleSendLog`, `handleLogTail`, `handleDomainDetail`, `handleDeleteConfirm`, а также во всех новых хендлерах, которые появятся в этой и следующей фазе. `layout.html` получает `.Active` наравне с остальными полями (`Title`, `User`), т.к. оборачивает все content-шаблоны одним и тем же кодом — ничего специально прокидывать через `content` не нужно.
- Активный пункт рендерится как `<span aria-current="page" class="active">`, а не `<a>` (не ссылка сама на себя), остальные — обычные ссылки.
- CSS: `.nav [aria-current]` — визуально выделенное состояние (цвет/подчёркивание/фон), уже в стиле остальной палитры `layout.html`.
### B. Раздел настроек аккаунта (логин, пароль)
Сейчас нет способа сменить логин или пароль администратора после `/setup` — только пересоздание состояния БД. `internal/store/admin.go` содержит только `CreateAdmin`/`GetAdmin`, без `Update*`.
- **Store:** `UpdateAdmin(username, passwordHash string) error` (один UPDATE по `id = 1`).
- **Web:** новый маршрут `GET/POST /account` в authed-группе ([web.go](../internal/web/web.go)); `handleAccount` показывает форму (текущий логин, поля «новый логин», «текущий пароль», «новый пароль», «подтверждение нового пароля»); `handlePostAccount` проверяет текущий пароль (`bcrypt.CompareHashAndPassword`, как в `handleLogin`) перед применением изменений, затем вызывает `UpdateAdmin`.
- Ссылка «Account» — в общей навигации (пункт A), рядом с `{{.User}}`/«Sign out».
- **Security:** та же lockout-защита, что у логина (rate-limit по неверному «текущему паролю», чтобы `/account` нельзя было использовать для брутфорса пароля обходным путём); при успешной смене пароля — инвалидировать все активные in-memory сессии, кроме текущей (заставить перелогиниться остальные при параллельном доступе — здесь один админ, так что риск минимален, но защищает от кражи старой cookie).
- **Шаблон:** `account.html` по образцу `login.html`/`setup.html` (`.card.narrow`).
### C. Backup/migration — отдельная страница, раздельные блоки
Сейчас на [dashboard.html](../internal/web/templates/dashboard.html) (строки 57–82) полный бэкап
(«Download full backup») и импорт домена («Import a domain») — **два разных по смыслу и риску
действия** — свалены в одну карточку «Backup & migration» посреди страницы со списком доменов.
Экспорт одного домена при этом уже (правильно) живёт отдельно, на самой странице домена
([domain_detail.html:181](../internal/web/templates/domain_detail.html)).
- Новый маршрут `GET /backup` (authed) с собственным пунктом в общей навигации (пункт A) — например «Backup».
- На дашборде вместо целой карточки остаётся одна ссылка/карточка-указатель на `/backup` (или пункт навигации целиком заменяет карточку — решить при реализации, что выглядит чище).
- На странице `/backup`**две отдельные карточки**, не одна:
1. **Full backup** — тот же текст и форма (`POST /backup`), что сейчас, без изменений в логике.
2. **Import a domain** — та же форма (`POST /domains/import`), без изменений в логике.
- Домен-экспорт на `domain_detail.html` не переносится — он уже привязан к конкретному домену и корректно расположен рядом с его данными; трогать не нужно.
- Handler: новый `handleBackupPage` рендерит `backup.html`; существующие `handleBackup`/`handleImportDomain` не меняются (те же POST-маршруты).
**Готово, когда:** нав рендерится один раз из `layout.html` (не копипастой по content-шаблонам), поэтому присутствует на каждой странице панели, включая `domain_detail.html`/`domain_delete.html` (где нав-ссылок сейчас нет вовсе) и на всех будущих страницах этой и следующей фазы без отдельной правки каждой из них; текущий пункт визуально выделен и не дублируется как кликабельная ссылка на себя; `/account` позволяет сменить логин и/или пароль администратора с проверкой текущего пароля; после смены пароля старые сессии, кроме текущей, недействительны; `/backup` — отдельная страница с двумя визуально раздельными карточками (полный бэкап; импорт домена), дашборд больше не показывает эти формы напрямую; `gofmt`/`vet`/`test`/`docker build` зелёные.
**Риски:** смена логина не должна затронуть ничего, кроме таблицы `admin` (это учётная запись панели, не SASL-логины приложений — они хранятся отдельно в `applications`, см. Фазу 4); нужно убедиться, что rate-limit смены пароля не создаёт новый вектор lockout-DoS сверх уже описанного в п. A.1 открытых вопросов; вынос backup/import на отдельную страницу — чисто вёрстка, логика обработчиков `/backup` и `/domains/import` не меняется, так что риск регресса минимален.
### D. Параметры подключения клиента на странице домена
Сейчас [domain_detail.html](../internal/web/templates/domain_detail.html) показывает DKIM-запись и
таблицу приложений (логин/режим адресов), но нигде на странице нет данных, нужных, чтобы **настроить
почтовый клиент/скрипт для отправки** — сервер, порт, тип шифрования. Карточка «New application
password» (строки 17–27) показывает только логин/пароль конкретного приложения — этого недостаточно
без сервера/порта/шифрования рядом.
- Новая карточка **«Sending server settings»** на `domain_detail.html`, рядом с карточкой DKIM (до или после — решить при вёрстке), с фиксированными для всего инстанса значениями:
- **Server:** `{{.Hostname}}` (то же значение `SELFPOST_HOSTNAME`, которое уже используется для setup-ссылки и SASL realm — сервер уже хранит его в `Server.cfg.Hostname`, [web.go:25](../internal/web/web.go); прокинуть в данные шаблона `domain_detail`, аналогично тому, как это уже сделано для `dashboard`/`setup`).
- **Port 465 — SSL/TLS (implicit)** — всегда доступен (primary listener, spec 5).
- **Port 587 — STARTTLS (submission)** — показывать строку только если включён `SUBMISSION_ENABLE` (сейчас это чисто deploy-время env, [.env.example:10](../deploy/.env.example); нужно завести `Config.SubmissionEnabled bool` в [web.go](../internal/web/web.go), прокинуть из `os.Getenv("SUBMISSION_ENABLE")` в [cmd/panel/main.go](../cmd/panel/main.go) по аналогии с `Hostname`). Если submission выключен — строку не показывать вовсе, а не показывать «недоступно», чтобы не путать.
- **Username:** логин конкретного приложения — не общий для домена; сослаться на таблицу «Applications» ниже на той же странице, а не дублировать значение здесь (оно меняется per-application).
- **Password:** пояснение, что пароль отображается один раз при создании/регенерации приложения (уже описано в карточке `NewCred`) — здесь не показываем.
- Значения read-only (`.code`, как остальные DNS-блоки), без форм — это справочная информация, не настройка.
**Готово (доп. к критерию блока A/B/C):** страница домена показывает сервер/порт(ы)/шифрование, необходимые для настройки клиента отправки, без необходимости смотреть в документацию или `.env`; порт 587 отображается только когда submission действительно включён на этом инстансе.
**Риски:** `SubmissionEnabled` — deploy-time флаг (`docker-compose.yml`), а не что-то, что панель может проверить рантаймом (например, слушает ли порт 587 реально) — если оператор выставил `SUBMISSION_ENABLE=true` в `.env`, но не перезапустил compose с проброшенным портом 587, страница покажет строку, которая не работает. Отметить это как известное ограничение (как и остальные deploy/env-производные показатели), не решать в рамках этой фазы усложнением (например, реальной проверкой прослушивания порта — это ближе к `/status`, не к странице домена).
### E. Кнопка «Copy» на полях, которые переносятся во внешние сервисы
Значения из карточек `.code` сейчас нужно выделять мышью вручную — DKIM-запись (name/value),
логин/пароль нового приложения, будущие поля из пункта D (сервер) регулярно копируются в другой
интерфейс (DNS-панель регистратора, почтовый клиент, скрипт). Тривиально добавить копирование одной
кнопкой.
- Общий JS-хелпер в [layout.html](../internal/web/templates/layout.html) (без библиотек): `navigator.clipboard.writeText(text)`, вызываемый по клику маленькой кнопки/иконки рядом с каждым `.code`-элементом; кратковременная визуальная обратная связь (например, текст кнопки на 1–2 секунды меняется на «Copied»).
- Разметка: небольшая обёртка `.code-row` (код + кнопка `Copy`) вместо голого `.code`, значение бралось из `textContent` элемента (не из отдельного JS-массива — тогда не рассинхронизируется с тем, что реально показано).
- Применить везде, где сейчас есть `.code` для значений, которые логично куда-то переносить: DKIM `Record.Name`/`Record.Value` ([domain_detail.html:35](../internal/web/templates/domain_detail.html), [domain_detail.html:41](../internal/web/templates/domain_detail.html)), `NewCred.Login`/`NewCred.Password` ([domain_detail.html:23](../internal/web/templates/domain_detail.html), [domain_detail.html:25](../internal/web/templates/domain_detail.html)), поля карточки «Sending server settings» из пункта D. Таблица приложений (`td.code` с логином) — тоже кандидат, но менее приоритетно (короткое значение, легко выделить руками).
- **Секьюрити:** `navigator.clipboard.writeText` требует secure context (HTTPS или `localhost`) — в проде это всегда так (панель форсирует HTTPS), в dev-режиме с `PANEL_COOKIE_SECURE=false` по голому HTTP кнопка может тихо не сработать в некоторых браузерах. Не городить `document.execCommand('copy')`-fallback ради dev-режима — задокументировать как известное ограничение и/или обернуть в `try/catch`, чтобы отсутствие Clipboard API не ломало страницу, а просто оставляло текст доступным для ручного выделения.
**Готово:** у DKIM-записи, нового пароля приложения и параметров подключения (сервер) есть кнопка «Copy», по клику значение оказывается в буфере обмена и показывается краткое подтверждение.
**Риски:** нет побочных эффектов на бэкенд — чистая клиентская правка; единственный нюанс — secure-context ограничение Clipboard API в dev, описанное выше.
### F. Скрывать поле адресов в режиме «Any address»
Обе формы с выбором режима адресов — «Add an application» ([domain_detail.html:163171](../internal/web/templates/domain_detail.html)) и «Edit mode» на существующем приложении ([domain_detail.html:7279](../internal/web/templates/domain_detail.html)) — всегда показывают textarea «Addresses», даже когда выбран `Any address of the domain` (wildcard), где это поле не используется и просто игнорируется сервером. Из-за этого неочевидно, что поле относится только к режиму «List».
- Чистый клиентский JS (без изменений на сервере — валидация `mode`/`addresses` в `internal/app` не трогается, т.к. в wildcard-режиме бэкенд и так игнорирует содержимое textarea): на `change` у `<select name="mode">` показывать/прятать соответствующий `<label>`+`<textarea addresses>` через `hidden`/`display:none`, в зависимости от того, выбран ли `$.Wildcard` или `$.List`.
- Начальное состояние при загрузке страницы — тоже должно учитывать текущее значение `select` (важно для формы «Edit mode», где `select` уже может быть предзаполнен значением `List`, а не только для формы добавления, где по умолчанию `Wildcard`).
- Реализовать один небольшой переиспользуемый скрипт (или `<script>` внизу `domain_detail.html`), т.к. на странице несколько экземпляров этой пары select+textarea (форма добавления + одна на каждое существующее приложение в таблице).
**Готово:** при выборе «Any address of the domain» поле «Addresses» скрывается (и в форме добавления, и в «Edit mode» для существующих приложений); при выборе «Specific addresses (list)» — снова показывается с уже введённым значением, если было.
**Риски:** нет — чисто клиентское поведение, серверная валидация уже игнорирует `addresses` для wildcard-режима, так что скрытие поля ничего не меняет по сути обработки формы.
**Модель:** Sonnet (UI + рутинный CRUD, не риск-критичный тракт доставки).
**Зависимости:** не блокируется другими фазами; вводит общий `nav`-partial и `Active`-механизм, которым позже пользуется Фаза 13 (добавляет туда пункт «Status» и переносит «Domains» на `/domains`) — предпочтительно делать Фазу 12 первой.
---
## Фаза 13 (v1.x) — Страница статуса сервиса + DNS-проверка доменов
**Статус:** запланирована, не начата.
@@ -197,7 +87,7 @@ password» (строки 17–27) показывает только логин/
- Список доменов (карточки «Add a sending domain» + «Domains» из [dashboard.html](../internal/web/templates/dashboard.html)) переезжает на новый маршрут `GET /domains` (тот же `handleDashboard`, просто перевешенный на другой путь; `POST /domains` для добавления домена остаётся как есть — коллизий с `GET /domains` нет, разные методы).
- Корневой `GET /{$}` начинает рендерить `/status` (страницу из блока A) — либо редиректом `/{$}``/status`, либо status-хендлер напрямую вешается и на `/`, и на `/status` (без редиректа, чуть дешевле). Редирект проще и не создаёт двух путей для одного контента — предпочтительный вариант.
- **Логин:** `handleLogin` после успешной аутентификации сейчас редиректит на `/` — поведение не меняется (просто `/` теперь означает статус, а не домены), правки в `handlers_auth.go` не требуется.
- **Навигация (Фаза 12.A):** пункт «Status» становится первым в общем `nav`-partial и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
- **Навигация:** пункт «Status» становится первым в общем `nav`-partial ([layout.html](../internal/web/templates/layout.html), Фаза 12) и получает `Active == "status"`; пункт «Domains» указывает на `/domains` вместо `/`. Это расширяет список `Active`-значений, заведённый в Фазе 12, а не меняет его архитектуру.
- Везде, где по коду сейчас зашит редирект/ссылка на `/` как «страница доменов» (например, `<a class="back" href="/">← Domains</a>` в `queue.html`, `sendlog.html`, `logtail.html`, `domain_detail.html`, `domain_delete.html`), ссылку нужно поменять на `/domains`.
**Готово (доп. к критерию блока A/B):** `GET /` открывает `/status`; список доменов доступен по `GET /domains`; все ссылки «← Domains» и пункт навигации «Domains» ведут на `/domains`; логин после успешной аутентификации попадает на страницу статуса.
@@ -237,7 +127,7 @@ password» (строки 17–27) показывает только логин/
**Модель:** Sonnet (UI + рутинные DNS-lookup, не риск-критичный тракт доставки/безопасности).
**Зависимости:** использует общий `nav`-partial и `Active`-механизм навигации, вводимые Фазой 12 — реализовывать после неё (или расширить тот же PR); использует уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
**Зависимости:** нет — общий `nav`-partial и `Active`-механизм навигации уже введены Фазой 12; использует также уже реализованные `internal/postfix.Queue()`, DKIM-логику домена, паттерны HTMX-фрагментов из Фазы 7.
---
@@ -248,7 +138,7 @@ password» (строки 17–27) показывает только логин/
**Цель:** довести до кода два уже принятых, но пока не реализованных решения из
раздела [A. Безопасность](#a-безопасность--hardening-сверх-обязательного-76)
(остальные пункты раздела A либо уже реализованы — п.1, либо являются открытыми
вопросами без решения — п.3/4, либо уже вынесены в свою фазу — п.6/Фаза 12).
вопросами без решения — п.3/4, либо уже реализованы в Фазе 12 — п.6).
### A. Security-заголовки ответа (пункт A.2)