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