From fc53ae1314f5bcb131b4a5c1e2c0d52fad2ee55a Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Sat, 1 Aug 2026 21:34:59 +0300 Subject: [PATCH] panel: shared nav, account settings, backup page, connection settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 16 +++ README.md | 10 +- cmd/panel/httpserver.go | 1 + cmd/panel/main.go | 19 ++- docs/implementation-plan.md | 126 ++-------------- docs/progress.md | 5 +- internal/store/admin.go | 23 +++ internal/store/admin_test.go | 45 ++++++ internal/web/handlers_account.go | 168 ++++++++++++++++++++++ internal/web/handlers_apps.go | 42 +++--- internal/web/handlers_backup.go | 32 ++++- internal/web/handlers_domains.go | 32 ++--- internal/web/handlers_monitor.go | 11 +- internal/web/session.go | 26 ++++ internal/web/session_test.go | 36 +++++ internal/web/static/panel.js | 64 +++++++++ internal/web/templates.go | 10 ++ internal/web/templates/account.html | 32 +++++ internal/web/templates/backup.html | 32 +++++ internal/web/templates/dashboard.html | 34 ----- internal/web/templates/domain_delete.html | 10 +- internal/web/templates/domain_detail.html | 71 ++++++--- internal/web/templates/layout.html | 53 +++++++ internal/web/templates/logtail.html | 15 +- internal/web/templates/queue.html | 15 +- internal/web/templates/sendlog.html | 15 +- internal/web/templates_test.go | 47 ++++++ internal/web/web.go | 12 +- 28 files changed, 720 insertions(+), 282 deletions(-) create mode 100644 internal/store/admin_test.go create mode 100644 internal/web/handlers_account.go create mode 100644 internal/web/session_test.go create mode 100644 internal/web/static/panel.js create mode 100644 internal/web/templates/account.html create mode 100644 internal/web/templates/backup.html create mode 100644 internal/web/templates_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1a61f..1606b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +- panel: navigation bar is now rendered once from the shared layout, so every + authenticated page has it — including the domain page and the delete + confirmation, which had no navigation links at all — and the current page is + highlighted instead of silently missing from the list. +- panel: new *Account* page to change the administrator's username and/or + password (the current password is required, throttled on the same limiter as + the login form). Changing the password invalidates all other sessions. +- panel: *Backup & migration* moved off the domain list onto its own *Backup* + page, with the full backup and the domain import as two separate cards. +- panel: the domain page now shows the *Sending server settings* (server, + port and encryption) needed to configure a mail client; port 587 is listed + only when `SUBMISSION_ENABLE=true` for this deployment. +- panel: *Copy* buttons on the DKIM record, on a newly issued application + login/password and on the sending server name. +- panel: the *Addresses* field is hidden while an application's address mode is + *Any address of the domain*, where the server ignores it. - ci: disable provenance attestation on release image push, so the ghcr.io manifest list shows only `linux/amd64`/`linux/arm64` (no `unknown/unknown`). - security: optionally honour `X-Forwarded-For` for login/setup rate-limiting diff --git a/README.md b/README.md index 16cc38c..685edb9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ docker compose up -d This starts SelfPost alone; it assumes Apache is already installed on the host as the reverse proxy (see below) and expects certificates at `./certs`. The first log line (`docker compose logs -f`) prints the one-time setup link — -open it to create the admin account. +open it to create the admin account. That username and password can be changed +later from the panel's *Account* page (changing the password signs out every +other session). ## Reverse proxy (mandatory) @@ -101,7 +103,7 @@ Two related but distinct operations — spec 7.5: - **Full backup** (whole `/data`: SQLite, all domains' DKIM keys, all applications' SASL credentials, `manifest.json` with the version that - created it): panel button (dashboard → *Backup & migration*), or from the + created it): panel button (*Backup* → *Full backup*), or from the host: ```sh docker exec selfpost-backup > selfpost-backup.tar.gz @@ -113,8 +115,8 @@ Two related but distinct operations — spec 7.5: known version, there'd be no way to tell which image restoring a given backup actually requires. -- **Export/import a single domain** (dashboard → domain page → *Export - domain*): moves one domain — its DKIM key and its applications' **working** +- **Export/import a single domain** (domain page → *Export domain* to write the + file, *Backup* → *Import a domain* to read it back in): moves one domain — its DKIM key and its applications' **working** SASL passwords — to a different SelfPost instance without regenerating anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full restore, this works across different hostnames/instances. diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index b721e04..bf28d7f 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -28,6 +28,7 @@ func serveHTTP(ctx context.Context, cfg config, st *store.Store) error { srvApp, err := web.New(st, domains, apps, web.Config{ Hostname: cfg.hostname, CookieSecure: cfg.cookieSecure, + SubmissionEnabled: cfg.submissionEnabled, MailLogPath: cfg.mailLog, DataDir: cfg.dataDir, DBPath: cfg.dbPath, diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 2c6087a..54f0456 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -52,13 +52,14 @@ type config struct { mailLog string retentionDays int - dataDir string - dbPath string - manifestPath string - setupTokenPath string - hostname string - cookieSecure bool - trustedProxies []*net.IPNet + dataDir string + dbPath string + manifestPath string + setupTokenPath string + hostname string + cookieSecure bool + submissionEnabled bool + trustedProxies []*net.IPNet opendkimDir string dkimSelectorDef string @@ -86,6 +87,10 @@ func loadConfig() config { // Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a // development-only escape hatch for testing over plain HTTP. cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false", + // Whether this deployment also runs the 587 submission listener. The + // panel only displays it as a client connection setting; the comparison + // matches postfix-config.sh, which enables the listener on "true" alone. + submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true", // Reverse-proxy addresses allowed to supply X-Forwarded-For for // rate-limiting (plan.md item A.1). Empty by default: an untrusted peer's // XFF header is trivially forgeable, so it's ignored unless the panel is diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index f987361..4d28326 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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-шаблоны сохраняют только то, что специфично для страницы (заголовок `

`, специфичные действия вроде будущего «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` не нужно. -- Активный пункт рендерится как ``, а не `` (не ссылка сама на себя), остальные — обычные ссылки. -- 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:163–171](../internal/web/templates/domain_detail.html)) и «Edit mode» на существующем приложении ([domain_detail.html:72–79](../internal/web/templates/domain_detail.html)) — всегда показывают textarea «Addresses», даже когда выбран `Any address of the domain` (wildcard), где это поле не используется и просто игнорируется сервером. Из-за этого неочевидно, что поле относится только к режиму «List». - -- Чистый клиентский JS (без изменений на сервере — валидация `mode`/`addresses` в `internal/app` не трогается, т.к. в wildcard-режиме бэкенд и так игнорирует содержимое textarea): на `change` у ` + @@ -161,14 +190,16 @@ value="{{.FormLogin}}" required> - - - +
+ + +
{{if .Error}}

{{.Error}}

{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 6afe353..2205193 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -6,6 +6,7 @@ {{.Title}} +
+{{if .User}}{{template "nav" .}}{{end}} {{template "content" .}}
{{end}} + +{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so + every authenticated page has it without the page's own template having to + remember to include it; .Active names the current page so it is highlighted + instead of linking to itself. Unauthenticated pages (login, setup) carry no + .User and get no nav. */}} +{{define "nav"}} +
+{{end}} diff --git a/internal/web/templates/logtail.html b/internal/web/templates/logtail.html index 9e1d835..b5e0eb6 100644 --- a/internal/web/templates/logtail.html +++ b/internal/web/templates/logtail.html @@ -1,18 +1,5 @@ {{define "content"}} -
-

Mail log

-
- {{.User}} - Domains - Send log - Queue -
- -
-
-
- -← Domains +

Mail log

mail.log tail

diff --git a/internal/web/templates/queue.html b/internal/web/templates/queue.html index a3c1806..f739f60 100644 --- a/internal/web/templates/queue.html +++ b/internal/web/templates/queue.html @@ -1,18 +1,5 @@ {{define "content"}} -
-

Mail queue

-
- {{.User}} - Domains - Send log - Log -
- -
-
-
- -← Domains +

Mail queue

postqueue -p

diff --git a/internal/web/templates/sendlog.html b/internal/web/templates/sendlog.html index 1b25d80..f00f051 100644 --- a/internal/web/templates/sendlog.html +++ b/internal/web/templates/sendlog.html @@ -1,18 +1,5 @@ {{define "content"}} -
-

Send log

-
- {{.User}} - Domains - Queue - Log -
- -
-
-
- -← Domains +

Send log

Filter

diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go new file mode 100644 index 0000000..7b3394d --- /dev/null +++ b/internal/web/templates_test.go @@ -0,0 +1,47 @@ +package web + +import ( + "bytes" + "strings" + "testing" +) + +// The navigation is rendered from the layout, not copied into each page, so +// every page template must resolve it. This is what makes "the nav is on every +// authenticated page" a structural property instead of a checklist item. +func TestEveryPageResolvesNav(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + for name, page := range tmpl.pages { + if page.Lookup("nav") == nil { + t.Errorf("page %q does not resolve the shared nav template", name) + } + } +} + +func TestNavMarksActivePage(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + var buf bytes.Buffer + err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{ + "User": "admin", + "Active": "queue", + }) + if err != nil { + t.Fatalf("execute nav: %v", err) + } + out := buf.String() + if !strings.Contains(out, `Queue`) { + t.Errorf("active page is not marked:\n%s", out) + } + if strings.Contains(out, `href="/queue"`) { + t.Errorf("active page still links to itself:\n%s", out) + } + if !strings.Contains(out, `href="/sendlog"`) { + t.Errorf("inactive pages are not linked:\n%s", out) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index d896202..88399fb 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -28,6 +28,11 @@ type Config struct { // to true (spec 7.6.6); it exists as a knob only so the panel can be tested // over plain HTTP in development, never for production. CookieSecure bool + // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also + // runs the 587/STARTTLS submission listener next to the primary 465 one + // (spec 5). The panel only reports it on the domain page's connection + // settings; it is a deploy-time flag, not something the panel can verify. + SubmissionEnabled bool // MailLogPath is where Postfix's delivery log lives, read by the mail.log // monitoring view (spec 7.2.13). It is the same path the log-tailer role // follows in cmd/panel. @@ -132,7 +137,12 @@ func (s *Server) Handler() http.Handler { authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) authed.HandleFunc("POST /reload", s.handleReload) - // Full-server backup download (spec 7.5.A). + // Administrator's own panel credentials. + authed.HandleFunc("/account", s.handleAccount) + + // Backup and migration: the page with both actions (spec 7.5.A-B), and the + // full-server backup download itself. + authed.HandleFunc("GET /backup", s.handleBackupPage) authed.HandleFunc("POST /backup", s.handleBackup) // Monitoring screens (spec 7.2.11-13): each page and its HTMX polling