feat(web): complete localized sync server console

This commit is contained in:
mirivlad 2026-07-17 07:02:11 +08:00
parent 0014910638
commit e8605f15a2
41 changed files with 1494 additions and 162 deletions

View File

@ -230,32 +230,61 @@ The server embeds its public, account, and administrator interface in the Go
binary. It has no CDN, npm build, external font, or remote analytics
dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`,
and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user
review their own devices and revoke one only after entering their password.
review and search only their own devices, see confirmation state and connection
times, and revoke one only after entering their password. The reusable layout,
templates, CSS, JavaScript, and local SVG live below `internal/server/web/` and
are embedded with `go:embed`; there is no separate frontend build.
`/admin/login` opens the administrator console. Its sidebar provides overview,
users, devices, vaults, storage, audit, SMTP settings, and diagnostics. User
blocking is immediate; device revocation and SMTP changes require the current
users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists
use bounded server-side search, filters, whitelisted sort order, and pagination.
Administrators can create, edit, confirm, block, reset, and delete users; revoke
devices; and permanently remove only a previously revoked device. A browser
password reset generates a random password and exposes it once on a `no-store`
page; it is never placed in the URL, audit log, cookies, or database plaintext.
Destructive
browser actions use the shared local confirmation dialog. Blocking, credential
changes, device actions, cleanup, and SMTP changes require the current
administrator password again. Admin HTML is deliberately a normal server
rendered control plane; the existing `/admin/api/...` endpoints remain for
automation.
The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its
values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then
`web.default_locale`, then English. The choice survives login and logout.
Registration is controlled by `web.allow_registration`; when disabled the
public registration page does not expose account creation.
`web.default_locale`, then English. The choice survives login and logout. A
separate short-lived HttpOnly form token protects the language chooser and all
anonymous browser POST forms; it is distinct from the server-side session CSRF
token. Registration is controlled by `web.allow_registration`; when disabled
the public registration page does not expose account creation.
The admin console also provides bounded user/device/audit lists, a vault detail
view with aggregates only (never file payloads), safe retention cleanup, and a
sanitized diagnostics download. General web settings are stored in the existing
`config.yml`; SMTP passwords are not returned to a browser form.
The overview reports operational counts and readiness warnings. Vault details
show only metadata (devices, sequence, operation count, activity, and blob
usage), never file contents or operation `payload_json`. Diagnostics download
is sanitized: it excludes paths, tokens, passwords, hashes, and payloads.
General web settings are stored in the existing `config.yml`; public URL and
registration policy can be changed there through the console, while transport
limits remain read-only. SMTP passwords are never returned to a browser form.
All browser mutations use POST and validate a server-side session plus CSRF
token. The server returns security headers including a restrictive CSP,
token; anonymous forms use the separate public-form token described above.
The server returns security headers including a restrictive CSP,
`frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The
console must still be deployed behind the HTTPS reverse proxy described below:
secure cookies are enabled when HTTPS is detected through a trusted proxy.
Run the local interactive browser smoke (Chromium plus Node's built-in
`undici`; no npm install) with:
```bash
./scripts/smoke-web.sh
```
It exercises language switching, admin login/navigation, temporary-user
creation, block/unblock confirmation, device pairing/revocation, filtering,
logout, and desktop/mobile screenshots. It starts an isolated temporary server
and removes its data and screenshots on exit; it is not a reverse-proxy or
production-deployment test.
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
to one user and vault. The server derives the stored device ID and operation

View File

@ -22,9 +22,12 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login")
return
}
if !s.requirePublicWebMutation(w, r, "/admin/login") {
return
}
user := r.FormValue("username")
pass := r.FormValue("password")
if !s.allowRate(w, r, "login", user) {
if !s.allowWebRate(w, r, "login", user, "/admin/login") {
return
}
if !s.cfg.CheckAdmin(user, pass) {

View File

@ -15,7 +15,7 @@ import (
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusNotFound)
s.renderPageStatus(w, r, "error", webPage{Title: "error.notFound", Heading: "error.notFound", Message: "error.notFound", BackURL: "/"}, http.StatusNotFound)
return
}
jsonErr(w, 404, "not found")

View File

@ -102,6 +102,9 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
}
tokenStr = req.Token
} else if err := r.ParseForm(); err == nil {
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
tokenStr = r.FormValue("token")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")

View File

@ -37,12 +37,15 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
return
}
if !s.requirePublicWebMutation(w, r, "/register") {
return
}
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
if username == "" || email == "" || password == "" {
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
return
}
if !s.allowRate(w, r, "register", email) {
if !s.allowWebRate(w, r, "register", email, "/register") {
return
}
if err := validatePassword(password); err != "" {
@ -104,12 +107,15 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if email == "" {
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
return
}
if !s.allowRate(w, r, "forgot", email) {
if !s.allowWebRate(w, r, "forgot", email, "/forgot") {
return
}
var userID string
@ -163,12 +169,15 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm")
if token == "" || password == "" || confirm == "" {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"})
return
}
if !s.allowRate(w, r, "reset", "") {
if !s.allowWebRate(w, r, "reset", "", "/forgot") {
return
}
if err := validatePassword(password); err != "" {
@ -205,8 +214,11 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return
}
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password")
if !s.allowRate(w, r, "login", login) {
if !s.allowWebRate(w, r, "login", login, "/login") {
return
}
var userID, hash string
@ -239,11 +251,20 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
return
}
var username, email string
if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil {
var confirmed int
if err := s.db.QueryRow("SELECT username, email, confirmed FROM server_users WHERE id=?", userID).Scan(&username, &email, &confirmed); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id WHERE ud.user_id=? ORDER BY d.created_at DESC`, userID)
query := strings.TrimSpace(r.URL.Query().Get("q"))
where := " WHERE ud.user_id=?"
args := []interface{}{userID}
if query != "" {
like := "%" + query + "%"
where += " AND (d.name LIKE ? OR d.vault_id LIKE ? OR d.client_version LIKE ?)"
args = append(args, like, like, like)
}
rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id`+where+` ORDER BY d.created_at DESC`, args...)
if err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
@ -267,11 +288,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
flash := r.URL.Query().Get("error")
if flash != "error.invalidCredentials" {
flash := r.URL.Query().Get("flash")
flashError := false
if flash == "" {
flash = r.URL.Query().Get("error")
flashError = flash != ""
}
if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" {
flash = ""
}
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash})
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, UserConfirmed: confirmed != 0, Devices: devices, Flash: flash, FlashError: flashError, List: webList{Query: query}})
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
@ -353,12 +379,21 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
password = req.Password
}
if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
if password == "" {
if password == "" {
if formRequest {
http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
}
return
}
if formRequest {
if !s.allowWebRate(w, r, "auth-test", session.SubjectID, "/dashboard") {
return
}
} else if !s.allowRate(w, r, "auth-test", session.SubjectID) {
return
}
var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err)
@ -387,7 +422,7 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
if formRequest {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther)
return
}
jsonOK(w, map[string]string{"status": "revoked"})

View File

@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
@ -64,33 +65,65 @@ func (s *Server) blobStorageWritable() bool {
// ServerStats is intentionally independent from the web UI so a future
// admin panel can expose operational data without coupling to templates.
type ServerStats struct {
Users int `json:"users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
Users int `json:"users"`
ActiveUsers int `json:"active_users"`
BlockedUsers int `json:"blocked_users"`
UnconfirmedUsers int `json:"unconfirmed_users"`
ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"`
Operations int `json:"operations"`
Operations24h int `json:"operations_24h"`
Blobs int `json:"blobs"`
BlobReferences int `json:"blob_references"`
OrphanBlobs int `json:"orphan_blobs"`
TempUploads int `json:"temp_uploads"`
ExpiredSessions int `json:"expired_sessions"`
ExpiredTokens int `json:"expired_email_tokens"`
AuditEvents int `json:"audit_events"`
DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"`
LastCleanupAt string `json:"last_cleanup_at"`
}
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats
now := time.Now().UTC()
queries := []struct {
query string
target *int
}{
{"SELECT COUNT(*) FROM server_users", &stats.Users},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers},
{"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers},
{"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
{"SELECT COUNT(*) FROM server_blobs", &stats.Blobs},
{"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences},
{"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs},
{"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents},
}
for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err
}
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil {
return ServerStats{}, err
}
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err
}
@ -112,5 +145,14 @@ func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
}); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
}
if entries, err := os.ReadDir(s.blobsDir); err == nil {
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".upload-") {
stats.TempUploads++
}
}
} else if !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err)
}
return stats, nil
}

View File

@ -77,6 +77,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Имя пользователя или email уже заняты.",
"error.emailRequired": "Введите email.",
"error.passwordMismatch": "Пароли не совпадают.",
"error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.",
"error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.",
"error.notFound": "Страница не найдена.",
"error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.",
"home.unavailableTitle": "Сервер временно недоступен",
"home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.",
"error.invalidCredentials": "Неверное имя пользователя или пароль.",
"error.internal": "Внутренняя ошибка. Повторите попытку позже.",
"admin.eyebrow": "Администрирование сервера",
@ -86,7 +92,7 @@ var _translations = map[string]map[string]string{
"admin.access": "Доступ",
"admin.activeDevices": "Активные устройства",
"admin.operations": "Операции",
"admin.vaults": "Vault",
"admin.vaults": "Хранилища",
"admin.storage": "Хранилище",
"admin.audit": "Аудит",
"admin.settings": "Настройки",
@ -122,11 +128,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.",
"user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства",
"user.emailConfirmed": "Email подтверждён",
"user.emailUnconfirmed": "Email не подтверждён",
"user.filterDevices": "Поиск устройств",
"user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.",
"user.deviceRevoked": "Устройство отозвано.",
"user.noDevices": "Устройств пока нет",
"device.name": "Устройство",
"device.vault": "Vault",
"device.vault": "Хранилище",
"device.version": "Версия клиента",
"device.lastSeen": "Последняя активность",
"device.created": "Подключено",
"device.status": "Статус",
"device.active": "Активно",
"device.revoked": "Отозвано",
@ -227,10 +239,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
"admin.settingsSaved": "Настройки сервера сохранены.",
"admin.smtpSaved": "Настройки SMTP сохранены.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "Один или несколько компонентов сервера не готовы к работе.",
"admin.warningSMTP": "SMTP не настроен: письма подтверждения и сброса пароля не будут отправляться.",
"admin.warningOperations": "Журнал операций быстро растёт. Не удаляйте его без checkpoint-механизма.",
"admin.ops24h": "за 24 часа",
"admin.sort": "Сортировка",
"admin.created": "Создан",
"admin.ip": "IP",
"admin.message": "Сообщение",
"admin.severity": "Важность",
"admin.info": "Информация",
"admin.warningLevel": "Предупреждение",
"admin.errorLevel": "Ошибка",
"admin.publicURL": "Публичный URL",
"admin.saveSettings": "Сохранить настройки",
"admin.trustedProxies": "Доверенные reverse proxy",
"admin.network": "Сеть",
"admin.limits": "Лимиты и retention",
"admin.readOnlyConfig": "Лимиты задаются конфигурацией сервера и доступны здесь только для просмотра.",
"admin.maxJSONBody": "Максимальный JSON body",
"admin.maxPushOperations": "Операций в push",
"admin.maxPullPage": "Операций в pull page",
"admin.maxBlobBytes": "Максимальный размер blob",
"admin.maxVaultBlobBytes": "Квота blob vault",
"admin.maxUserBlobBytes": "Квота blob пользователя",
"admin.sequence": "Последняя sequence",
"admin.refresh": "Обновить",
"admin.copyDiagnostics": "Скопировать диагностику",
"admin.copied": "Скопировано",
"admin.blobReferences": "Ссылки на blobs",
"admin.orphanBlobs": "Blob без ссылок",
"admin.tempUploads": "Временные загрузки",
"admin.expiredSessions": "Истёкшие сессии",
"admin.expiredTokens": "Истёкшие email-токены",
"admin.lastCleanup": "Последняя очистка",
"admin.cleanupDone": "Безопасная очистка завершена.",
"audit.other": "Другое событие",
"audit.deviceAuthFailed": "Ошибка авторизации устройства",
"audit.devicePaired": "Устройство подключено",
"audit.deviceRevoked": "Устройство отозвано",
"audit.deviceDeleted": "Отозванное устройство удалено",
"audit.rateLimited": "Сработало ограничение попыток",
"audit.retentionCleanup": "Выполнена безопасная очистка",
"audit.smtpSettingsUpdated": "Настройки SMTP изменены",
"audit.smtpTestFailed": "Проверка SMTP не удалась",
"audit.smtpTestPassed": "Проверка SMTP прошла",
"audit.userBlockChanged": "Статус блокировки пользователя изменён",
"audit.userConfirmed": "Email пользователя подтверждён",
"audit.userCreated": "Пользователь создан",
"audit.userDeleted": "Пользователь удалён",
"audit.userPasswordReset": "Пароль пользователя сброшен",
"audit.userUpdated": "Пользователь изменён",
"audit.webSettingsUpdated": "Общие настройки изменены",
"status.ok": "Работает",
"status.degraded": "Требует внимания",
"status.available": "Доступно",
"status.unavailable": "Недоступно",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
"common.error": "Ошибка",
"common.warning": "Предупреждения",
"admin.filterPlaceholder": "Поиск...",
"admin.email": "Email",
"admin.actions": "Действия",
@ -249,8 +322,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ",
"admin.generatePassword": "Сгенерировать одноразовый пароль",
"admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.",
"admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.",
"admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить",
"admin.deleteDevice": "Удалить устройство",
"admin.confirmUser": "Подтвердить email",
"admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.",
"admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать",
@ -320,6 +399,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Username or email is already in use.",
"error.emailRequired": "Enter an email address.",
"error.passwordMismatch": "Passwords do not match.",
"error.invalidPublicURL": "Enter a valid absolute public URL using http or https.",
"error.rateLimited": "Too many attempts. Wait and try again.",
"error.notFound": "Page not found.",
"error.deviceMustBeRevoked": "Only a revoked device can be deleted.",
"home.unavailableTitle": "Server is temporarily unavailable",
"home.unavailableMessage": "The server is not ready to serve requests yet. Try again later.",
"error.invalidCredentials": "Invalid username or password.",
"error.internal": "Internal error. Please try again later.",
"admin.eyebrow": "Server administration",
@ -365,11 +450,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.",
"user.account": "My account",
"user.devices": "Connected devices",
"user.emailConfirmed": "Email confirmed",
"user.emailUnconfirmed": "Email not confirmed",
"user.filterDevices": "Search devices",
"user.connectInstruction": "Connect another device in Verstak: open sync settings and pair it with this server.",
"user.deviceRevoked": "Device revoked.",
"user.noDevices": "No devices yet",
"device.name": "Device",
"device.vault": "Vault",
"device.version": "Client version",
"device.lastSeen": "Last activity",
"device.created": "Connected",
"device.status": "Status",
"device.active": "Active",
"device.revoked": "Revoked",
@ -470,10 +561,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Test passed",
"admin.smtpRequired": "Enter the SMTP server, port, and sender.",
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.",
"admin.settingsSaved": "Server settings saved.",
"admin.smtpSaved": "SMTP settings saved.",
"admin.smtpStartTLS": "STARTTLS",
"admin.smtpTLS": "TLS",
"admin.warningReadiness": "One or more server components are not ready.",
"admin.warningSMTP": "SMTP is not configured: confirmation and password-reset emails will not be sent.",
"admin.warningOperations": "The operation log is growing quickly. Do not prune it without a checkpoint mechanism.",
"admin.ops24h": "in the last 24 hours",
"admin.sort": "Sort",
"admin.created": "Created",
"admin.ip": "IP",
"admin.message": "Message",
"admin.severity": "Severity",
"admin.info": "Info",
"admin.warningLevel": "Warning",
"admin.errorLevel": "Error",
"admin.publicURL": "Public URL",
"admin.saveSettings": "Save settings",
"admin.trustedProxies": "Trusted reverse proxies",
"admin.network": "Network",
"admin.limits": "Limits and retention",
"admin.readOnlyConfig": "Limits are configured on the server and are read-only here.",
"admin.maxJSONBody": "Maximum JSON body",
"admin.maxPushOperations": "Operations per push",
"admin.maxPullPage": "Operations per pull page",
"admin.maxBlobBytes": "Maximum blob size",
"admin.maxVaultBlobBytes": "Vault blob quota",
"admin.maxUserBlobBytes": "User blob quota",
"admin.sequence": "Latest sequence",
"admin.refresh": "Refresh",
"admin.copyDiagnostics": "Copy diagnostics",
"admin.copied": "Copied",
"admin.blobReferences": "Blob references",
"admin.orphanBlobs": "Unreferenced blobs",
"admin.tempUploads": "Temporary uploads",
"admin.expiredSessions": "Expired sessions",
"admin.expiredTokens": "Expired email tokens",
"admin.lastCleanup": "Last cleanup",
"admin.cleanupDone": "Safe cleanup completed.",
"audit.other": "Other event",
"audit.deviceAuthFailed": "Device authentication failed",
"audit.devicePaired": "Device paired",
"audit.deviceRevoked": "Device revoked",
"audit.deviceDeleted": "Revoked device deleted",
"audit.rateLimited": "Rate limit triggered",
"audit.retentionCleanup": "Safe cleanup completed",
"audit.smtpSettingsUpdated": "SMTP settings updated",
"audit.smtpTestFailed": "SMTP test failed",
"audit.smtpTestPassed": "SMTP test passed",
"audit.userBlockChanged": "User block status changed",
"audit.userConfirmed": "User email confirmed",
"audit.userCreated": "User created",
"audit.userDeleted": "User deleted",
"audit.userPasswordReset": "User password reset",
"audit.userUpdated": "User updated",
"audit.webSettingsUpdated": "General settings updated",
"status.ok": "Operational",
"status.degraded": "Needs attention",
"status.available": "Available",
"status.unavailable": "Unavailable",
"admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...",
"common.ok": "OK",
"common.error": "Error",
"common.warning": "Warnings",
"admin.filterPlaceholder": "Search...",
"admin.email": "Email",
"admin.actions": "Actions",
@ -492,8 +644,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New password: ",
"admin.generatePassword": "Generate one-time password",
"admin.oneTimePasswordNotice": "Save this password now: it is shown only once.",
"admin.oneTimePasswordHint": "It cannot be retrieved after leaving this page. The user should change it after signing in.",
"admin.resetBtn": "Reset",
"admin.deleteUser": "Delete",
"admin.deleteDevice": "Delete device",
"admin.confirmUser": "Confirm email",
"admin.deleteUserConfirm": "Delete this user and all related devices, vault associations, blobs, and operations? This cannot be undone.",
"admin.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock",

View File

@ -18,12 +18,12 @@ var ratePolicies = map[string]RatePolicy{
"admin-reset": {Limit: 8, Window: time.Hour},
}
// allowRate applies an IP limit and, where a login/account is supplied, an
// additional bounded account bucket. It never logs submitted credentials.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
// rateRetryAfter applies an IP limit and, where a login/account is supplied,
// an additional bounded account bucket. It never logs submitted credentials.
func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) {
policy, ok := ratePolicies[action]
if !ok {
return true
return 0, true
}
ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window)
@ -40,13 +40,34 @@ func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, accou
if seconds < 1 {
seconds = 1
}
w.Header().Set("Retry-After", strconvItoa(seconds))
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
return seconds, false
}
}
return true
return 0, true
}
// allowRate writes API-compatible JSON for transport endpoints.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false
}
// allowWebRate is the HTML counterpart of allowRate. Browser forms receive a
// localized error page instead of a machine-readable API error.
func (s *Server) allowWebRate(w http.ResponseWriter, r *http.Request, action, account, back string) bool {
retryAfter, allowed := s.rateRetryAfter(r, action, account)
if allowed {
return true
}
w.Header().Set("Retry-After", strconvItoa(retryAfter))
s.renderWebError(w, r, http.StatusTooManyRequests, "error.rateLimited", back)
return false
}
func strconvItoa(value int) string {

View File

@ -37,6 +37,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/admin", s.handleAdminRoot)
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction)
s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb)
s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)

View File

@ -8,20 +8,23 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Server struct {
db *sql.DB
dbPath string
cfg *Config
blobsDir string
mux *http.ServeMux
limiter *rateLimiter
web *webRenderer
startedAt time.Time
db *sql.DB
dbPath string
cfg *Config
blobsDir string
mux *http.ServeMux
limiter *rateLimiter
web *webRenderer
startedAt time.Time
secretMu sync.Mutex
webSecrets map[string]oneTimeWebSecret
}
// Version and BuildCommit are assigned through -ldflags during release builds.
@ -89,13 +92,14 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, fmt.Errorf("web templates: %w", err)
}
s := &Server{
db: db,
dbPath: dbPath,
cfg: cfg,
blobsDir: blobsDir,
limiter: newRateLimiter(nil),
web: web,
startedAt: time.Now().UTC(),
db: db,
dbPath: dbPath,
cfg: cfg,
blobsDir: blobsDir,
limiter: newRateLimiter(nil),
web: web,
startedAt: time.Now().UTC(),
webSecrets: make(map[string]oneTimeWebSecret),
}
s.mux = http.NewServeMux()
return s, nil

View File

@ -640,12 +640,14 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string)
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"token": {token},
"password": {password},
"confirm": {password},
"token": {token},
"password": {password},
"confirm": {password},
"locale_csrf": {"test-public-csrf"},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"})
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response

View File

@ -26,17 +26,22 @@ body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.stack { display:grid; gap:1rem; }
.stack label { display:grid; gap:.35rem; font-weight:650; }
.stack input,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
.stack input:not([type="checkbox"]),.stack select,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); }
.flash { padding:.75rem; border-radius:8px; }
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; }
.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; }
.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; }
.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; }
.dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; }
.table-card { padding:1.25rem; }
.table-scroll { overflow-x:auto; }
table { width:100%; border-collapse:collapse; }
table { width:100%; min-width:720px; border-collapse:collapse; }
th,td { padding:.75rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; }
.badge { display:inline-flex; padding:.2rem .55rem; border-radius:999px; font-size:.8rem; font-weight:700; }
.badge.ok { background:#173d36; color:#91ecd9; }.badge.danger { background:#4c2529; color:#ffd9d9; }
.badge.warning { background:#4b421d; color:#ffed9a; }
.inline-form { display:flex; min-width:250px; gap:.4rem; }.inline-form input { min-width:120px; }
.link-button { padding:0; border:0; background:transparent; color:var(--muted); font:inherit; cursor:pointer; }
.admin-shell { display:grid; grid-template-columns:220px minmax(0,1fr); gap:1.5rem; }
@ -49,4 +54,5 @@ details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.c
.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin:0; }
@media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } }

View File

@ -1,7 +1,38 @@
document.addEventListener("change", function (event) {
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit();
});
const confirmationDialog = document.getElementById("confirm-dialog");
let confirmationForm = null;
let confirmationButton = null;
document.addEventListener("click", function (event) {
const button = event.target.closest("[data-confirm]");
if (button && !window.confirm(button.dataset.confirm)) event.preventDefault();
if (!button || !button.form || !confirmationDialog) return;
event.preventDefault();
confirmationForm = button.form;
confirmationButton = button;
document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm;
confirmationDialog.showModal();
});
if (confirmationDialog) {
confirmationDialog.addEventListener("close", function () {
if (confirmationDialog.returnValue === "confirm" && confirmationForm) {
confirmationForm.requestSubmit(confirmationButton);
}
confirmationForm = null;
confirmationButton = null;
});
}
document.addEventListener("click", async function (event) {
const button = event.target.closest("[data-copy-diagnostics]");
if (!button || !navigator.clipboard) return;
try {
const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" });
if (!response.ok) return;
await navigator.clipboard.writeText(await response.text());
button.textContent = button.dataset.copiedLabel;
} catch (_) {
// The downloadable JSON link remains available when clipboard access is unavailable.
}
});

View File

@ -1,29 +0,0 @@
{{define "admin"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
</aside>
<div class="admin-content">
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>{{end}}
{{if eq .AdminPage "dashboard"}}<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.Stats.LastSyncAt}}</dd></dl></section>
{{else if eq .AdminPage "users"}}<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button secondary" type="submit">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><details><summary>{{t $.Locale "admin.manage"}}</summary><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.newPassword"}}<input name="new_password" type="password" required></label><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.resetPassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button danger" type="submit">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "devices"}}<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&amp;vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><input name="password" type="password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit">{{t .Locale "admin.runCleanup"}}</button></form>
{{else if eq .AdminPage "audit"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.At}}</td></tr>{{else}}<tr><td colspan="4" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
{{else if eq .AdminPage "settings"}}<p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.smtpTitle"}}</h1><section class="card panel"><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp"><label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}"></label><label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" value="{{.SMTP.Port}}"></label><label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}"></label><label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label><label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>STARTTLS</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>TLS</option></select></label><label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" value="{{.SMTP.From}}"></label><label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" value="{{.SMTP.ServerURL}}"></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button></form></section>
{{else if eq .AdminPage "diagnostics"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{.Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{.Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{.Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>{{end}}
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&amp;status={{.List.Status}}&amp;page={{.List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&amp;status={{.List.Status}}&amp;page={{.List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>{{end}}
</div>
</section>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "admin_audit"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.event"}}<input name="event" value="{{.List.Event}}" maxlength="160"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "admin.severity"}}<select name="severity"><option value="">{{t .Locale "admin.all"}}</option><option value="info" {{if eq .List.Severity "info"}}selected{{end}}>{{t .Locale "admin.info"}}</option><option value="warning" {{if eq .List.Severity "warning"}}selected{{end}}>{{t .Locale "admin.warningLevel"}}</option><option value="error" {{if eq .List.Severity "error"}}selected{{end}}>{{t .Locale "admin.errorLevel"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.time"}}</th><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.severity"}}</th><th>{{t .Locale "admin.message"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{webtime $.Locale .At}}</td><td>{{auditlabel $.Locale .Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.IP}}</td><td><span class="badge {{if eq .Severity "error"}}danger{{else if eq .Severity "warning"}}warning{{else}}ok{{end}}">{{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}}</span></td><td>{{.Message}}</td></tr>{{else}}<tr><td colspan="7" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "admin_dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1>
<div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}} · {{.Stats.ActiveUsers}} {{t .Locale "device.active"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}} · {{.Stats.RevokedDevices}} {{t .Locale "device.revoked"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{.Stats.Operations24h}} {{t .Locale "admin.ops24h"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article></div>
{{if .Warnings}}<section class="card panel warning"><h2>{{t .Locale "common.warning"}}</h2>{{range .Warnings}}<p>{{t $.Locale .}}</p>{{end}}</section>{{end}}
<section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .Stats.LastSyncAt}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl></section>
<section class="card table-card"><h2>{{t .Locale "admin.audit"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{auditlabel $.Locale .Event}}</td><td>{{webtime $.Locale .At}}</td></tr>{{else}}<tr><td colspan="2" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
</div></section>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "admin_devices"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "device.vault"}}<input name="vault" value="{{.List.Vault}}" maxlength="160"></label><label>{{t .Locale "device.version"}}<input name="version" value="{{.List.Version}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="name" {{if eq .List.Sort "name"}}selected{{end}}>{{t .Locale "device.name"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}{{if .TokenHint}}<small class="muted">{{.TokenHint}}</small>{{end}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{else}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="delete-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "admin.deleteDevice"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="9" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "admin_diagnostics"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{boollabel .Locale .Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{boollabel .Locale .Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/admin/diagnostics">{{t .Locale "admin.refresh"}}</a><button class="button secondary" type="button" data-copy-diagnostics="/admin/diagnostics.json" data-copy-label="{{t .Locale "admin.copyDiagnostics"}}" data-copied-label="{{t .Locale "admin.copied"}}">{{t .Locale "admin.copyDiagnostics"}}</button><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>
</div></section>
{{end}}

View File

@ -1,2 +1,2 @@
{{define "admin_login"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}

View File

@ -0,0 +1,14 @@
{{define "admin_nav"}}
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
</aside>
{{end}}

View File

@ -0,0 +1,8 @@
{{define "admin_password_result"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content narrow-content">
<p class="eyebrow">{{t .Locale "admin.resultTitle"}}</p><h1>{{t .Locale "admin.resetPassword"}}</h1>
<section class="card panel warning"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret">{{.OneTimeSecret}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
<a class="button secondary" href="/admin/users">{{t .Locale "admin.users"}}</a>
</div></section>
{{end}}

View File

@ -1,2 +1,47 @@
{{define "admin_settings"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.settings"}}</h1><section class="card panel"><h2>{{t .Locale "admin.general"}}</h2><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings"><label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label><label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label><label><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label><label>{{t .Locale "field.password"}}<input name="password" type="password" required></label><button class="button primary">{{t .Locale "admin.saveUser"}}</button></form></section><section class="card panel"><h2>{{t .Locale "admin.smtpTitle"}}</h2><p class="muted">{{t .Locale "admin.smtpConfigured"}}</p><a class="button secondary" href="/admin/dashboard">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
{{define "content"}}
<section class="admin-shell">
{{template "admin_nav" .}}
<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.settings"}}</p>
<h1>{{t .Locale "admin.settings"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel">
<h2>{{t .Locale "admin.general"}}</h2>
<form method="post" action="/admin/action" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings">
<label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label>
<label>{{t .Locale "admin.publicURL"}}<input name="public_url" type="url" value="{{.PublicURL}}" placeholder="https://sync.example.test"></label>
<label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label>
<label class="check-field"><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button primary">{{t .Locale "admin.saveSettings"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.network"}}</h2><dl class="details"><dt>{{t .Locale "admin.trustedProxies"}}</dt><dd>{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}</dd></dl></section>
<section class="card panel">
<h2>{{t .Locale "admin.smtpTitle"}}</h2>
<p class="muted">{{t .Locale "admin.smtpConfigured"}}</p>
<form method="post" action="/admin/action" class="stack">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp">
<label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}" autocomplete="off"></label>
<label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" inputmode="numeric" value="{{.SMTP.Port}}"></label>
<label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}" autocomplete="username"></label>
<label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label>
<label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>{{t .Locale "admin.smtpStartTLS"}}</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>{{t .Locale "admin.smtpTLS"}}</option></select></label>
<label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" type="email" value="{{.SMTP.From}}"></label>
<label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" type="url" value="{{.SMTP.ServerURL}}"></label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button>
</form>
<form method="post" action="/admin/action" class="inline-form top-gap">
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp-test">
<label>{{t .Locale "field.email"}}<input name="test_to" type="email" placeholder="{{.SMTP.From}}"></label>
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
<button class="button secondary" type="submit">{{t .Locale "admin.smtpTest"}}</button>
</form>
</section>
<section class="card panel"><h2>{{t .Locale "admin.limits"}}</h2><p class="muted">{{t .Locale "admin.readOnlyConfig"}}</p><dl class="details"><dt>{{t .Locale "admin.maxJSONBody"}}</dt><dd>{{webbytes .Limits.MaxJSONBody}}</dd><dt>{{t .Locale "admin.maxPushOperations"}}</dt><dd>{{.Limits.MaxPushOperations}}</dd><dt>{{t .Locale "admin.maxPullPage"}}</dt><dd>{{.Limits.MaxPullPage}}</dd><dt>{{t .Locale "admin.maxBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxBlobBytes}}</dd><dt>{{t .Locale "admin.maxVaultBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxVaultBlobBytes}}</dd><dt>{{t .Locale "admin.maxUserBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxUserBlobBytes}}</dd></dl></section>
</div>
</section>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "admin_storage"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1>
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
<div class="stat-grid"><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.blobReferences"}}</dt><dd>{{.Stats.BlobReferences}}</dd><dt>{{t .Locale "admin.orphanBlobs"}}</dt><dd>{{.Stats.OrphanBlobs}}</dd><dt>{{t .Locale "admin.tempUploads"}}</dt><dd>{{.Stats.TempUploads}}</dd><dt>{{t .Locale "admin.expiredSessions"}}</dt><dd>{{.Stats.ExpiredSessions}}</dd><dt>{{t .Locale "admin.expiredTokens"}}</dt><dd>{{.Stats.ExpiredTokens}}</dd><dt>{{t .Locale "admin.lastCleanup"}}</dt><dd>{{webtime .Locale .Stats.LastCleanupAt}}</dd></dl></section>
<p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action" class="inline-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><label class="sr-only">{{t .Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit" data-confirm="{{t .Locale "admin.revokeConfirm"}}">{{t .Locale "admin.runCleanup"}}</button></form>
</div></section>
{{end}}

View File

@ -0,0 +1,9 @@
{{define "admin_users"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div>
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="username" {{if eq .List.Sort "username"}}selected{{end}}>{{t .Locale "field.username"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.vaults"}}</th><th>{{t .Locale "admin.lastSeen"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{.Vaults}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><details><summary>{{t $.Locale "admin.manage"}}</summary>{{if not .Confirmed}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="confirm-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.confirmUser"}}</button></form>{{end}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.resetPasswordConfirm"}}">{{t $.Locale "admin.generatePassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.deleteUserConfirm"}}">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="8" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
</div></section>
{{end}}

View File

@ -0,0 +1,7 @@
{{define "admin_vaults"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1>
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&amp;vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
</div></section>
{{end}}

View File

@ -1,2 +1,2 @@
{{define "confirm"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}

View File

@ -1,2 +1,7 @@
{{define "dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<section class="card table-card"><h2>{{t .Locale "user.devices"}}</h2>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>{{end}}
{{define "content"}}
<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}} · {{if .UserConfirmed}}<span class="badge ok">{{t .Locale "user.emailConfirmed"}}</span>{{else}}<span class="badge danger">{{t .Locale "user.emailUnconfirmed"}}</span>{{end}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>
{{if .Flash}}<p class="flash {{if .FlashError}}error{{else}}success{{end}}" role="{{if .FlashError}}alert{{else}}status{{end}}">{{t .Locale .Flash}}</p>{{end}}
<section class="card panel"><p class="muted">{{t .Locale "user.connectInstruction"}}</p></section>
<section class="card table-card"><div class="section-heading"><h2>{{t .Locale "user.devices"}}</h2><form class="list-filter" method="get" action="/dashboard"><label class="sr-only">{{t .Locale "user.filterDevices"}}</label><input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "user.filterDevices"}}"><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form></div>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>
{{end}}

View File

@ -1,5 +1,5 @@
{{define "forgot"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.forgotTitle"}}</h1><p class="muted">{{t .Locale "auth.forgotDescription"}}</p>
<form method="post" action="/forgot" class="stack"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
<form method="post" action="/forgot" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
{{end}}

View File

@ -5,6 +5,6 @@
<h1>{{t .Locale "home.heading"}}</h1>
<p class="lead">{{t .Locale "home.description"}}</p>
<div class="actions"><a class="button primary" href="/login">{{t .Locale "home.login"}}</a>{{if .AllowRegistration}}<a class="button secondary" href="/register">{{t .Locale "home.register"}}</a>{{end}}</div>
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{.Status}}</span></p>
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{statuslabel .Locale .Status}}</span></p>
</section>
{{end}}

View File

@ -17,7 +17,7 @@
</nav>
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
<input type="hidden" name="from" value="{{.CurrentURL}}">
{{if .CSRF}}<input type="hidden" name="csrf_token" value="{{.CSRF}}">{{end}}
<input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}">
<label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
<select id="locale" name="locale" data-auto-submit>
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
@ -29,6 +29,7 @@
</header>
<main class="page-shell">{{template "content" .}}</main>
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-title"><form method="dialog" class="stack"><h2 id="confirm-dialog-title">{{t .Locale "admin.confirmTitle"}}</h2><p id="confirm-dialog-message"></p><div class="actions"><button class="button secondary" value="cancel">{{t .Locale "admin.modalCancel"}}</button><button class="button danger" value="confirm">{{t .Locale "admin.modalConfirm"}}</button></div></form></dialog>
<script src="/static/app.js" defer></script>
</body>
</html>

View File

@ -2,6 +2,6 @@
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/login" class="stack"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
<form method="post" action="/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
<p class="muted"><a href="/forgot">{{t .Locale "auth.forgot"}}</a>{{if .AllowRegistration}} · <a href="/register">{{t .Locale "auth.register"}}</a>{{end}}</p></section>
{{end}}

View File

@ -2,6 +2,6 @@
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/register" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
<form method="post" action="/register" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
<p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
{{end}}

View File

@ -2,5 +2,5 @@
{{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1>
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
<form method="post" action="/reset" class="stack"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
<form method="post" action="/reset" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
{{end}}

View File

@ -0,0 +1,2 @@
{{define "unavailable"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="message-card card"><p class="eyebrow">{{t .Locale "home.eyebrow"}}</p><h1>{{t .Locale .Heading}}</h1><p class="muted">{{t .Locale .Message}}</p></section>{{end}}

View File

@ -1,2 +1,10 @@
{{define "vault_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p><div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article><article class="card stat"><strong>{{.VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p><a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
{{define "content"}}
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
<p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p>
<div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}</span></article><article class="card stat"><strong>{{webbytes .VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div>
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p></section>
<section class="card table-card"><h2>{{t .Locale "admin.devices"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th></tr></thead><tbody>{{range .VaultDevices}}<tr><td>{{.Name}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
<a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a>
</div></section>
{{end}}

View File

@ -6,6 +6,7 @@ import (
"encoding/hex"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -13,6 +14,38 @@ import (
"golang.org/x/crypto/bcrypt"
)
type oneTimeWebSecret struct {
Value string
ExpiresAt time.Time
}
// storeAdminOneTimeSecret keeps a generated password only long enough for the
// currently authenticated administrator to retrieve it once. It is never
// written to the database, URL, audit log, or cookie.
func (s *Server) storeAdminOneTimeSecret(sessionToken, secret string) {
s.secretMu.Lock()
defer s.secretMu.Unlock()
now := time.Now().UTC()
for key, value := range s.webSecrets {
if !now.Before(value.ExpiresAt) {
delete(s.webSecrets, key)
}
}
s.webSecrets[sha256Hex(sessionToken)] = oneTimeWebSecret{Value: secret, ExpiresAt: now.Add(5 * time.Minute)}
}
func (s *Server) takeAdminOneTimeSecret(sessionToken string) string {
s.secretMu.Lock()
defer s.secretMu.Unlock()
key := sha256Hex(sessionToken)
value, ok := s.webSecrets[key]
delete(s.webSecrets, key)
if !ok || !time.Now().UTC().Before(value.ExpiresAt) {
return ""
}
return value.Value
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
@ -21,6 +54,28 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
}
func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
return
}
if !s.requireAdminCookie(w, r) {
return
}
cookie, err := r.Cookie("admin_session")
if err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
secret := s.takeAdminOneTimeSecret(cookie.Value)
if secret == "" {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
w.Header().Set("Cache-Control", "no-store, max-age=0")
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users", OneTimeSecret: secret})
}
func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet)
@ -35,7 +90,7 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
return
}
var d webVaultDetail
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT o.op_id), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Operations, &d.LastActivity); err != nil {
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')='' THEN d.id END), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')!='' THEN d.id END), COUNT(DISTINCT o.op_id), COALESCE(MAX(o.server_sequence),0), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Active, &d.Revoked, &d.Operations, &d.Sequence, &d.LastActivity); err != nil {
jsonInternalError(w, err)
return
}
@ -44,7 +99,31 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
jsonInternalError(w, err)
return
}
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d})
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id WHERE d.user_id=? AND d.vault_id=? ORDER BY d.created_at DESC`, userID, vaultID)
if err != nil {
jsonInternalError(w, err)
return
}
defer rows.Close()
var devices []webAdminDevice
for rows.Next() {
var device webAdminDevice
var revoked, prefix, suffix string
if err := rows.Scan(&device.ID, &device.Name, &device.User, &device.Vault, &device.Version, &device.LastIP, &device.LastSeen, &revoked, &device.CreatedAt, &prefix, &suffix); err != nil {
jsonInternalError(w, err)
return
}
device.Revoked = revoked != ""
if prefix != "" || suffix != "" {
device.TokenHint = prefix + "…" + suffix
}
devices = append(devices, device)
}
if err := rows.Err(); err != nil {
jsonInternalError(w, err)
return
}
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, AdminPage: "vaults", VaultDetail: d, VaultDevices: devices})
}
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
@ -122,6 +201,20 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
}
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
switch page {
case "dashboard":
data.Audit, _, err = s.webAudit(webList{Page: 1, PerPage: 5})
if err == nil {
data.AdminDevices, _, err = s.webAdminDevices(webList{Page: 1, PerPage: 5})
}
if !data.Health.DatabaseReachable || !data.Health.BlobStorageWritable {
data.Warnings = append(data.Warnings, "admin.warningReadiness")
}
if s.smtpGet("smtp_host") == "" {
data.Warnings = append(data.Warnings, "admin.warningSMTP")
}
if stats.Operations > 100000 {
data.Warnings = append(data.Warnings, "admin.warningOperations")
}
case "users":
data.List = webListFromRequest(r)
data.AdminUsers, data.List, err = s.webAdminUsers(data.List)
@ -135,21 +228,38 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
data.Audit, data.List, err = s.webAudit(data.List)
case "settings":
data.SMTP = s.webSMTP()
switch r.URL.Query().Get("flash") {
case "settings_saved":
data.Flash = "admin.settingsSaved"
case "smtp_saved":
data.Flash = "admin.smtpSaved"
case "smtp_test_passed":
data.Flash = "admin.smtpPassed"
case "smtp_test_failed":
data.Flash = "admin.smtpTestFailed"
}
case "storage":
if r.URL.Query().Get("flash") == "cleanup_done" {
data.Flash = "admin.cleanupDone"
}
}
if err != nil {
log.Printf("admin %s: %v", page, err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
return
}
if page == "settings" {
s.renderPage(w, r, "admin_settings", data)
return
}
s.renderPage(w, r, "admin", data)
s.renderPage(w, r, "admin_"+page, data)
}
func webListFromRequest(r *http.Request) webList {
list := webList{Query: strings.TrimSpace(r.URL.Query().Get("q")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Page: 1, PerPage: 25}
trim := func(value string) string {
value = strings.TrimSpace(value)
if len(value) > 160 {
return value[:160]
}
return value
}
list := webList{Query: trim(r.URL.Query().Get("q")), Status: trim(r.URL.Query().Get("status")), Sort: trim(r.URL.Query().Get("sort")), User: trim(r.URL.Query().Get("user")), Vault: trim(r.URL.Query().Get("vault")), Version: trim(r.URL.Query().Get("version")), Event: trim(r.URL.Query().Get("event")), Severity: trim(r.URL.Query().Get("severity")), Page: 1, PerPage: 25}
if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 {
list.Page = value
}
@ -198,9 +308,14 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"username": "u.username COLLATE NOCASE ASC", "last_seen": "COALESCE(u.last_seen,'') DESC", "created": "u.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "u.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id),(SELECT COUNT(DISTINCT vd.vault_id) FROM server_devices vd WHERE vd.user_id=u.id AND COALESCE(vd.vault_id,'')!='') FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@ -209,7 +324,7 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
for rows.Next() {
var u webAdminUser
var confirmed, blocked int
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil {
return nil, list, err
}
u.Confirmed = confirmed != 0
@ -222,27 +337,44 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) {
where := ""
args := []interface{}{}
if list.Query != "" {
where = " WHERE (d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like, like)
}
if list.Status == "active" || list.Status == "revoked" {
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
like := "%" + list.Query + "%"
addCondition("(d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)", like, like, like)
}
if list.Status == "active" || list.Status == "revoked" {
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
addCondition(condition)
}
if list.User != "" {
addCondition("u.username LIKE ?", "%"+list.User+"%")
}
if list.Vault != "" {
addCondition("d.vault_id LIKE ?", "%"+list.Vault+"%")
}
if list.Version != "" {
addCondition("d.client_version LIKE ?", "%"+list.Version+"%")
}
var total int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
order := map[string]string{"name": "d.name COLLATE NOCASE ASC", "last_seen": "COALESCE(d.last_seen,'') DESC", "created": "d.created_at DESC"}[list.Sort]
if order == "" {
list.Sort = "created"
order = "d.created_at DESC"
}
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@ -250,10 +382,13 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error
var out []webAdminDevice
for rows.Next() {
var d webAdminDevice
var revoked string
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil {
var revoked, prefix, suffix string
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastIP, &d.LastSeen, &revoked, &d.CreatedAt, &prefix, &suffix); err != nil {
return nil, list, err
}
if prefix != "" || suffix != "" {
d.TokenHint = prefix + "…" + suffix
}
d.Revoked = revoked != ""
if d.LastSeen == "" {
d.LastSeen = "—"
@ -283,19 +418,37 @@ func (s *Server) webVaults() ([]webVault, error) {
func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
where := ""
args := []interface{}{}
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" {
where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)"
like := "%" + list.Query + "%"
args = append(args, like, like, like)
addCondition("(a.event_type LIKE ? OR a.user_id LIKE ? OR a.device_id LIKE ?)", like, like, like)
}
if list.Event != "" {
addCondition("a.event_type LIKE ?", "%"+list.Event+"%")
}
if list.User != "" {
addCondition("a.user_id LIKE ?", "%"+list.User+"%")
}
if list.Severity == "error" {
addCondition("(a.event_type LIKE '%failed%' OR a.event_type LIKE '%error%')")
} else if list.Severity == "warning" {
addCondition("a.event_type LIKE '%rate_limit%'")
}
var total int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log"+where, args...).Scan(&total); err != nil {
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log a"+where, args...).Scan(&total); err != nil {
return nil, list, err
}
list = finishWebList(list, total)
queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
rows, err := s.db.Query(`SELECT a.event_type,COALESCE(u.username,a.user_id,''),COALESCE(d.name,a.device_id,''),COALESCE(a.ip,''),COALESCE(a.message,''),a.created_at FROM server_audit_log a LEFT JOIN server_users u ON u.id=a.user_id LEFT JOIN server_devices d ON d.id=a.device_id`+where+` ORDER BY a.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, list, err
}
@ -303,14 +456,25 @@ func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
var out []webAudit
for rows.Next() {
var a webAudit
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil {
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.IP, &a.Message, &a.At); err != nil {
return nil, list, err
}
a.Severity = auditSeverity(a.Event)
out = append(out, a)
}
return out, list, rows.Err()
}
func auditSeverity(event string) string {
if strings.Contains(event, "failed") || strings.Contains(event, "error") {
return "error"
}
if strings.Contains(event, "rate_limit") || strings.Contains(event, "revoked") || strings.Contains(event, "blocked") {
return "warning"
}
return "info"
}
func (s *Server) webSMTP() webSMTP {
return webSMTP{Host: s.smtpGet("smtp_host"), Port: s.smtpGet("smtp_port"), User: s.smtpGet("smtp_user"), Security: s.smtpGet("smtp_security"), From: s.smtpGet("smtp_from"), ServerURL: s.smtpGet("server_url")}
}
@ -343,10 +507,19 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
if locale != "ru" && locale != "en" {
locale = "en"
}
publicURL := strings.TrimRight(strings.TrimSpace(r.FormValue("public_url")), "/")
if publicURL != "" {
parsed, err := url.ParseRequestURI(publicURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
s.renderWebError(w, r, http.StatusBadRequest, "error.invalidPublicURL", "/admin/settings")
return
}
}
s.cfg.mu.Lock()
s.cfg.Web.DefaultLocale = locale
s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on"
s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name"))
s.cfg.PublicURL = publicURL
err := s.cfg.saveLocked()
s.cfg.mu.Unlock()
if err != nil {
@ -354,7 +527,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
http.Redirect(w, r, "/admin/settings?flash=settings_saved", http.StatusSeeOther)
case "toggle-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
@ -412,14 +585,36 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
}
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "confirm-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id := r.FormValue("id")
result, err := s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", id)
if err != nil {
jsonInternalError(w, err)
return
}
if changed, err := result.RowsAffected(); err != nil || changed == 0 {
if err != nil {
jsonInternalError(w, err)
} else {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
}
return
}
s.auditLog("user_confirmed", id, "", s.clientIP(r), "confirmed by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
case "reset-user-password":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return
}
id, password := r.FormValue("id"), r.FormValue("new_password")
if err := validatePassword(password); err != "" {
s.renderWebError(w, r, http.StatusBadRequest, "error.passwordInvalid", "/admin/users")
id := r.FormValue("id")
password, err := randomSecret(16)
if err != nil {
jsonInternalError(w, err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@ -446,7 +641,13 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
cookie, err := r.Cookie("admin_session")
if err != nil {
jsonInternalError(w, err)
return
}
s.storeAdminOneTimeSecret(cookie.Value, password)
http.Redirect(w, r, "/admin/password-result", http.StatusSeeOther)
case "delete-user":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
@ -483,6 +684,43 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
}
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "delete-device":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
return
}
id := r.FormValue("id")
tx, err := s.db.Begin()
if err != nil {
jsonInternalError(w, err)
return
}
defer tx.Rollback()
var revoked string
if err := tx.QueryRow("SELECT COALESCE(revoked_at,'') FROM server_devices WHERE id=?", id).Scan(&revoked); err != nil {
if err == sql.ErrNoRows {
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/devices")
} else {
jsonInternalError(w, err)
}
return
}
if revoked == "" {
s.renderWebError(w, r, http.StatusConflict, "error.deviceMustBeRevoked", "/admin/devices")
return
}
for _, statement := range []string{"DELETE FROM server_user_devices WHERE device_id=?", "DELETE FROM server_devices WHERE id=?"} {
if _, err := tx.Exec(statement, id); err != nil {
jsonInternalError(w, err)
return
}
}
if err := tx.Commit(); err != nil {
jsonInternalError(w, err)
return
}
s.auditLog("device_deleted", "", id, s.clientIP(r), "revoked device deleted by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
case "smtp":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
@ -511,7 +749,29 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
http.Redirect(w, r, "/admin/settings?flash=smtp_saved", http.StatusSeeOther)
case "smtp-test":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
return
}
smtp := s.webSMTP()
to := strings.TrimSpace(r.FormValue("test_to"))
if to == "" {
to = smtp.From
}
if smtp.Host == "" || smtp.Port == "" || smtp.From == "" || to == "" {
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
if err := s.smtpTest(smtp.Host, smtp.Port, smtp.User, s.smtpGet("smtp_pass"), smtp.Security, smtp.From, to); err != nil {
log.Printf("admin SMTP test failed: %v", err)
s.auditLog("smtp_test_failed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
return
}
s.auditLog("smtp_test_passed", "", "", s.clientIP(r), "tested by administrator")
http.Redirect(w, r, "/admin/settings?flash=smtp_test_passed", http.StatusSeeOther)
case "cleanup":
if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage")
@ -522,7 +782,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return
}
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator")
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther)
default:
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
}

View File

@ -1,11 +1,15 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
)
const webLocaleCookieName = "verstak_locale"
const (
webLocaleCookieName = "verstak_locale"
webLocaleCSRFCookieName = "verstak_locale_csrf"
)
func isSupportedWebLocale(locale string) bool {
return locale == "en" || locale == "ru"
@ -69,3 +73,38 @@ func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale str
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60,
})
}
// webLocaleCSRF protects the public language-preference form without
// conflating it with session CSRF tokens. Its value is rendered by the server,
// while the matching cookie is HttpOnly and never read by client-side code.
func (s *Server) webLocaleCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(webLocaleCSRFCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token, err := randomSecret(32)
if err != nil {
return ""
}
http.SetCookie(w, &http.Cookie{
Name: webLocaleCSRFCookieName, Value: token, Path: "/", HttpOnly: true,
Secure: s.requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 24 * 60 * 60,
})
return token
}
func (s *Server) verifyWebLocaleCSRF(r *http.Request) bool {
cookie, err := r.Cookie(webLocaleCSRFCookieName)
if err != nil || cookie.Value == "" || !s.sameOrigin(r) {
return false
}
candidate := r.FormValue("locale_csrf")
return candidate != "" && subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) == 1
}
func (s *Server) requirePublicWebMutation(w http.ResponseWriter, r *http.Request, back string) bool {
if s.verifyWebLocaleCSRF(r) {
return true
}
s.renderWebError(w, r, http.StatusForbidden, "error.tryAgain", back)
return false
}

View File

@ -21,6 +21,16 @@ func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) {
}
}
func TestFormatWebTimeUsesSelectedLocale(t *testing.T) {
stamp := "2026-07-17T13:45:00Z"
if got := formatWebTime("ru", stamp); !strings.Contains(got, "17.07.2026") {
t.Fatalf("Russian timestamp = %q", got)
}
if got := formatWebTime("en", stamp); !strings.Contains(got, "Jul 17, 2026") {
t.Fatalf("English timestamp = %q", got)
}
}
func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) {
for key := range _translations["en"] {
if _, ok := _translations["ru"][key]; !ok {
@ -56,6 +66,20 @@ func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T
}
}
func TestConfirmationUsesLocalDialogInsteadOfBrowserPrompt(t *testing.T) {
layout, err := webFS.ReadFile("web/templates/layout.html")
if err != nil {
t.Fatal(err)
}
script, err := webFS.ReadFile("web/static/app.js")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(layout), `id="confirm-dialog"`) || !strings.Contains(string(script), ".showModal()") || strings.Contains(string(script), "window.confirm") {
t.Fatalf("local confirmation dialog is missing or browser prompt remains")
}
}
func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) {
entries, err := fs.Glob(webFS, "web/templates/*.html")
if err != nil {
@ -102,6 +126,22 @@ func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) {
}
}
func TestPublicHomeUsesUnavailablePageWhenReadinessFails(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if err := s.db.Close(); err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/", nil))
if res.Code != http.StatusServiceUnavailable || !strings.Contains(res.Body.String(), `<html lang="en">`) {
t.Fatalf("unavailable page=%d: %s", res.Code, res.Body.String())
}
}
func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@ -125,6 +165,39 @@ func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
}
}
func TestAdminPagesRenderWithActiveNavigationInBothLocales(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, _, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
for _, locale := range []string{"ru", "en"} {
for _, tc := range []struct{ path, active string }{
{"/admin/dashboard", "/admin/dashboard"}, {"/admin/users", "/admin/users"}, {"/admin/devices", "/admin/devices"}, {"/admin/vaults", "/admin/vaults"}, {"/admin/vault/?user=u1&vault=vault-a", "/admin/vaults"}, {"/admin/storage", "/admin/storage"}, {"/admin/audit", "/admin/audit"}, {"/admin/settings", "/admin/settings"}, {"/admin/diagnostics", "/admin/diagnostics"},
} {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `<html lang="`+locale+`">`) || !strings.Contains(res.Body.String(), `href="`+tc.active+`" aria-current="page"`) {
t.Fatalf("%s %s = %d; active navigation missing: %s", locale, tc.path, res.Code, res.Body.String())
}
}
}
}
func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@ -223,8 +296,9 @@ func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login"))
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
@ -243,8 +317,9 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
}
defer s.Close()
s.SetupRoutes()
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token"))
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token&locale_csrf=locale-test-token"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" {
@ -252,6 +327,52 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
}
}
func TestLocaleSelectionRejectsMissingOrMismatchedCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, tc := range []struct {
name, body, cookie string
}{
{"missing", "locale=ru&from=/login", ""},
{"mismatched", "locale=ru&from=/login&locale_csrf=other", "locale-test-token"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tc.cookie != "" {
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: tc.cookie})
}
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("locale CSRF status=%d, want 403", res.Code)
}
})
}
}
func TestPublicWebFormsRejectMissingCSRF(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, path := range []string{"/register", "/login", "/forgot", "/reset", "/admin/login"} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("username=alice&email=alice%40example.test&password=password&confirm=password"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("%s without public CSRF = %d, want 403", path, res.Code)
}
}
}
func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
@ -349,6 +470,198 @@ func TestAdminUserListSearchStatusAndPagination(t *testing.T) {
}
}
func TestAdminSettingsRendersAndSavesSMTPConfiguration(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
get.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
get.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
getResult := httptest.NewRecorder()
s.Handler().ServeHTTP(getResult, get)
if getResult.Code != http.StatusOK {
t.Fatalf("settings page = %d: %s", getResult.Code, getResult.Body.String())
}
for _, field := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
if !strings.Contains(getResult.Body.String(), `name="`+field+`"`) {
t.Fatalf("settings page is missing SMTP field %q: %s", field, getResult.Body.String())
}
}
body := "csrf_token=" + csrf + "&action=smtp&smtp_host=mail.example.test&smtp_port=587&smtp_user=mailer&smtp_pass=mail-secret&smtp_security=starttls&smtp_from=sync%40example.test&server_url=https%3A%2F%2Fsync.example.test&password=correct+horse+battery+staple"
post := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
post.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
post.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
postResult := httptest.NewRecorder()
s.Handler().ServeHTTP(postResult, post)
if postResult.Code != http.StatusSeeOther || postResult.Header().Get("Location") != "/admin/settings?flash=smtp_saved" {
t.Fatalf("save SMTP = %d %q: %s", postResult.Code, postResult.Header().Get("Location"), postResult.Body.String())
}
if got := s.smtpGet("smtp_host"); got != "mail.example.test" {
t.Fatalf("saved SMTP host = %q", got)
}
}
func TestUserDashboardOnlyRendersOwnFilteredDevices(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.SetupRoutes()
for _, user := range []struct{ id, name string }{{"user-a", "alice"}, {"user-b", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, name string }{{"device-a", "user-a", "Alice laptop"}, {"device-b", "user-b", "Bob workstation"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES (?, ?, ?, ?, 'vault', '2026-01-01T00:00:00Z')", device.id, device.name, "key-"+device.id, device.user); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id,device_id) VALUES (?,?)", device.user, device.id); err != nil {
t.Fatal(err)
}
}
token, _, err := s.createSession(sessionScopeUser, "user-a")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/dashboard?q=Alice", nil)
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("dashboard=%d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), "Alice laptop") || strings.Contains(res.Body.String(), "Bob workstation") {
t.Fatalf("dashboard leaked or missed device: %s", res.Body.String())
}
}
func TestAdminDeviceFiltersAndAuditSearchRemainBounded(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, user := range []struct{ id, name string }{{"u1", "alice"}, {"u2", "bob"}} {
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
t.Fatal(err)
}
}
for _, device := range []struct{ id, user, vault, version string }{{"d1", "u1", "vault-a", "2.0"}, {"d2", "u2", "vault-b", "1.0"}} {
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,client_version,created_at) VALUES (?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z')", device.id, device.id, "key-"+device.id, device.user, device.vault, device.version); err != nil {
t.Fatal(err)
}
}
devices, list, err := s.webAdminDevices(webList{User: "alice", Vault: "vault-a", Version: "2.0", Sort: "name", Page: 1, PerPage: 25})
if err != nil || len(devices) != 1 || devices[0].ID != "d1" || list.Sort != "name" {
t.Fatalf("filtered devices=%+v list=%+v err=%v", devices, list, err)
}
if _, err := s.db.Exec("INSERT INTO server_audit_log (event_type,user_id,message,created_at) VALUES ('device_paired','u1','safe','2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
audit, _, err := s.webAudit(webList{Event: "' OR 1=1 --", Page: 1, PerPage: 25})
if err != nil || len(audit) != 0 {
t.Fatalf("audit injection filter returned=%+v err=%v", audit, err)
}
}
func TestAdminCanConfirmUnconfirmedUserWithCSRFAndReauth(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',0,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=confirm-user&id=u1&password=correct+horse+battery+staple"
req := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/admin/users" {
t.Fatalf("confirm user=%d %q: %s", res.Code, res.Header().Get("Location"), res.Body.String())
}
var confirmed int
if err := s.db.QueryRow("SELECT confirmed FROM server_users WHERE id='u1'").Scan(&confirmed); err != nil || confirmed != 1 {
t.Fatalf("confirmed=%d err=%v", confirmed, err)
}
}
func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
s.SetupRoutes()
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','old',1,'2026-01-01T00:00:00Z')"); err != nil {
t.Fatal(err)
}
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
if err != nil {
t.Fatal(err)
}
if _, _, err := s.createSession(sessionScopeUser, "u1"); err != nil {
t.Fatal(err)
}
body := "csrf_token=" + csrf + "&action=reset-user-password&id=u1&password=correct+horse+battery+staple"
request := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/password-result" {
t.Fatalf("reset=%d %q: %s", response.Code, response.Header().Get("Location"), response.Body.String())
}
var sessions int
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_sessions WHERE scope='user' AND subject_id='u1'").Scan(&sessions); err != nil || sessions != 0 {
t.Fatalf("user sessions=%d err=%v", sessions, err)
}
resultRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
resultResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(resultResponse, resultRequest)
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "one-time-secret") {
t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String())
}
secondRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
secondResponse := httptest.NewRecorder()
s.Handler().ServeHTTP(secondResponse, secondRequest)
if secondResponse.Code != http.StatusSeeOther || secondResponse.Header().Get("Location") != "/admin/users" {
t.Fatalf("second password result=%d %q", secondResponse.Code, secondResponse.Header().Get("Location"))
}
}
func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en"

View File

@ -5,6 +5,8 @@ import (
"html/template"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@ -23,10 +25,15 @@ type webPage struct {
DefaultLocale string
Title string
ServerName string
PublicURL string
TrustedProxies string
Limits Limits
CurrentPath string
CurrentURL string
CSRF string
LocaleCSRF string
Flash string
FlashError bool
AllowRegistration bool
Version string
BuildCommit string
@ -37,9 +44,11 @@ type webPage struct {
FormAction string
BackURL string
Token string
OneTimeSecret string
Admin bool
UserName string
Email string
UserConfirmed bool
Devices []webDevice
AdminPage string
Stats ServerStats
@ -47,7 +56,9 @@ type webPage struct {
AdminUsers []webAdminUser
AdminDevices []webAdminDevice
Vaults []webVault
VaultDevices []webAdminDevice
Audit []webAudit
Warnings []string
SMTP webSMTP
List webList
VaultDetail webVaultDetail
@ -56,33 +67,50 @@ type webPage struct {
type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool
Devices int
Devices, Vaults int
}
type webAdminDevice struct {
ID, Name, User, Vault, Version, LastSeen, CreatedAt string
Revoked bool
ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string
Revoked bool
}
type webVault struct {
User, UserID, Vault string
Devices, Operations int
LastActivity string
}
type webAudit struct{ Event, User, Device, At string }
type webAudit struct{ Event, User, Device, IP, Message, Severity, At string }
type webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
type webList struct {
Query string
Status string
Page int
PerPage int
Total int
Pages int
Previous int
Next int
Query, Status, Sort, User, Vault, Version, Event, Severity string
Page int
PerPage int
Total int
Pages int
Previous int
Next int
}
func (list webList) params(page int) string {
values := url.Values{}
for key, value := range map[string]string{"q": list.Query, "status": list.Status, "sort": list.Sort, "user": list.User, "vault": list.Vault, "version": list.Version, "event": list.Event, "severity": list.Severity} {
if value != "" {
values.Set(key, value)
}
}
if page > 1 {
values.Set("page", strconvItoa(page))
}
if list.PerPage != 25 {
values.Set("per_page", strconvItoa(list.PerPage))
}
return values.Encode()
}
type webVaultDetail struct {
User, Vault, LastActivity string
Devices, Operations int
Devices, Active, Revoked int
Operations, Sequence int
BlobBytes int64
}
@ -99,7 +127,13 @@ type webDevice struct {
func newWebRenderer() (*webRenderer, error) {
funcs := template.FuncMap{
"t": func(locale, key string) string { return t(locale, key) },
"t": func(locale, key string) string { return t(locale, key) },
"webtime": func(locale, value string) string { return formatWebTime(locale, value) },
"webbytes": func(value int64) string { return formatWebBytes(value) },
"listparams": func(list webList, page int) string { return list.params(page) },
"auditlabel": func(locale, event string) string { return auditEventLabel(locale, event) },
"statuslabel": func(locale, status string) string { return statusLabel(locale, status) },
"boollabel": func(locale string, value bool) string { return boolLabel(locale, value) },
"short": func(value string, length int) string {
if len(value) <= length || length < 5 {
return value
@ -107,12 +141,12 @@ func newWebRenderer() (*webRenderer, error) {
return value[:length-1] + "…"
},
}
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html")
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html", "web/templates/admin_nav.html")
if err != nil {
return nil, err
}
renderer := &webRenderer{templates: make(map[string]*template.Template)}
for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail", "admin_settings"} {
for _, page := range []string{"home", "unavailable", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin_dashboard", "admin_users", "admin_devices", "admin_vaults", "admin_storage", "admin_audit", "admin_diagnostics", "admin_create_user", "admin_password_result", "vault_detail", "admin_settings"} {
clone, err := layout.Clone()
if err != nil {
return nil, err
@ -129,6 +163,74 @@ func newWebRenderer() (*webRenderer, error) {
return renderer, nil
}
func formatWebTime(locale, value string) string {
if value == "" {
return "—"
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return value
}
if locale == "ru" {
return parsed.Local().Format("02.01.2006 15:04")
}
return parsed.Local().Format("Jan 2, 2006 15:04")
}
func formatWebBytes(value int64) string {
if value < 1024 {
return strconvItoa(int(value)) + " B"
}
units := []string{"KB", "MB", "GB", "TB"}
amount := float64(value)
for _, unit := range units {
amount /= 1024
if amount < 1024 || unit == "TB" {
return strconv.FormatFloat(amount, 'f', 1, 64) + " " + unit
}
}
return "0 B"
}
func auditEventLabel(locale, event string) string {
keys := map[string]string{
"device_auth_failed": "audit.deviceAuthFailed",
"device_paired": "audit.devicePaired",
"device_revoked": "audit.deviceRevoked",
"device_deleted": "audit.deviceDeleted",
"rate_limit_exceeded": "audit.rateLimited",
"retention_cleanup": "audit.retentionCleanup",
"smtp_settings_updated": "audit.smtpSettingsUpdated",
"smtp_test_failed": "audit.smtpTestFailed",
"smtp_test_passed": "audit.smtpTestPassed",
"user_block_changed": "audit.userBlockChanged",
"user_confirmed": "audit.userConfirmed",
"user_created": "audit.userCreated",
"user_deleted": "audit.userDeleted",
"user_password_reset": "audit.userPasswordReset",
"user_updated": "audit.userUpdated",
"web_settings_updated": "audit.webSettingsUpdated",
}
if key := keys[event]; key != "" {
return t(locale, key)
}
return t(locale, "audit.other")
}
func statusLabel(locale, status string) string {
if status == "ok" {
return t(locale, "status.ok")
}
return t(locale, "status.degraded")
}
func boolLabel(locale string, value bool) string {
if value {
return t(locale, "status.available")
}
return t(locale, "status.unavailable")
}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
s.renderPageStatus(w, r, page, data, http.StatusOK)
}
@ -142,8 +244,12 @@ func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page s
data.LocalePreference = s.webLocalePreference(r)
data.DefaultLocale = s.cfg.Web.DefaultLocale
data.ServerName = s.cfg.Web.ServerName
data.PublicURL = s.cfg.PublicURL
data.TrustedProxies = strings.Join(s.cfg.TrustedProxies, ", ")
data.Limits = s.cfg.Limits
data.CurrentPath = r.URL.Path
data.CurrentURL = r.URL.RequestURI()
data.LocaleCSRF = s.webLocaleCSRF(w, r)
data.AllowRegistration = s.cfg.Web.AllowRegistration
data.Version = Version
data.BuildCommit = BuildCommit
@ -188,6 +294,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
return
}
status := s.healthStatus(r.Context())
if status.Status != "ok" {
s.renderPageStatus(w, r, "unavailable", webPage{Title: "home.unavailableTitle", Heading: "home.unavailableTitle", Message: "home.unavailableMessage"}, http.StatusServiceUnavailable)
return
}
s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status})
}
@ -200,6 +310,10 @@ func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) {
s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"})
return
}
if !s.verifyWebLocaleCSRF(r) {
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusForbidden)
return
}
locale := r.FormValue("locale")
if locale != "ru" && locale != "en" && locale != "system" {
locale = "system"

152
scripts/smoke-web-browser.mjs Executable file
View File

@ -0,0 +1,152 @@
#!/usr/bin/env node
// Browser smoke driver for scripts/smoke-web.sh. It uses Node's bundled
// undici WebSocket client and Chromium DevTools; no npm package is required.
import { createRequire } from "node:module";
import { writeFile } from "node:fs/promises";
const require = createRequire(import.meta.url);
const { WebSocket } = require("undici");
const [baseURL, outputDir] = process.argv.slice(2);
if (!baseURL || !outputDir) throw new Error("usage: smoke-web-browser.mjs <base-url> <output-dir>");
const debugURL = process.env.CHROME_DEBUG_URL || "http://127.0.0.1:9223";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(check, label) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (await check()) return;
await delay(100);
}
throw new Error(`timed out waiting for ${label}`);
}
const targets = await (await fetch(`${debugURL}/json/list`)).json();
const target = targets.find((item) => item.type === "page");
if (!target?.webSocketDebuggerUrl) throw new Error("Chromium DevTools page target is unavailable");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
let nextID = 1;
const pending = new Map();
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (!message.id) return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
if (message.error) request.reject(new Error(`${message.error.message} (${message.error.code})`));
else request.resolve(message.result);
});
function cdp(method, params = {}) {
const id = nextID++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const result = await cdp("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "browser evaluation failed");
return result.result.value;
}
async function navigate(path) {
await cdp("Page.navigate", { url: new URL(path, baseURL).href });
await waitFor(async () => String(await evaluate("location.href")).startsWith(new URL(path, baseURL).href), path);
await waitFor(() => evaluate("document.readyState === 'complete'"), `${path} document readiness`);
}
async function submit(selector, values, expectedPath) {
const payload = JSON.stringify(values);
await evaluate(`(() => { const form = document.querySelector(${JSON.stringify(selector)}); if (!form) throw new Error('form not found: ${selector}'); const values = ${payload}; for (const [name, value] of Object.entries(values)) { const input = form.querySelector('[name="' + name + '"]'); if (!input) throw new Error('input not found: ' + name); input.value = value; } form.requestSubmit(); })()`);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
async function screenshot(name) {
const image = await cdp("Page.captureScreenshot", { format: "png" });
await writeFile(`${outputDir}/${name}.png`, Buffer.from(image.data, "base64"));
}
async function confirmDialog(expectedPath) {
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === true"), "confirmation dialog");
await evaluate("document.querySelector('#confirm-dialog button[value=confirm]').click()");
await waitFor(() => evaluate("document.querySelector('#confirm-dialog')?.open === false"), "confirmation dialog close");
await delay(300);
await waitFor(async () => new URL(await evaluate("location.href")).pathname === expectedPath, expectedPath);
}
await cdp("Page.enable");
await cdp("Runtime.enable");
// Public locale selector, then a real admin login through the rendered form.
await navigate("/");
await screenshot("public-en");
await waitFor(() => evaluate("Boolean(document.querySelector('.locale-form select'))"), "locale selector");
await evaluate("document.querySelector('.locale-form select').value = 'ru'; document.querySelector('.locale-form').requestSubmit()");
await waitFor(() => evaluate("document.documentElement?.lang === 'ru'"), "Russian locale");
await screenshot("public-ru");
await navigate("/admin/login");
await submit('form[action="/admin/login"]', { username: "admin", password: "browser-smoke-admin-password" }, "/admin/dashboard");
// Seed data before rendering admin views, so screenshots cover non-empty
// users/devices/vaults/storage/audit states.
await navigate("/admin/create-user");
await submit('form[action="/admin/create-user"]', { username: "browser-smoke-user", email: "browser-smoke@example.test", password: "browser-smoke-password" }, "/admin/users");
const paired = await fetch(`${baseURL}/api/client/pair`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ login: "browser-smoke-user", password: "browser-smoke-password", device_name: "Browser smoke laptop", vault_id: "browser-smoke-vault", client_version: "smoke" }) });
if (!paired.ok) throw new Error(`pairing smoke device failed: ${paired.status}`);
await navigate("/admin/dashboard");
await screenshot("admin-dashboard");
// Core admin navigation with populated data.
for (const [path, name] of [["/admin/users", "admin-users"], ["/admin/devices", "admin-devices"], ["/admin/vaults", "admin-vaults"], ["/admin/storage", "admin-storage"], ["/admin/audit", "admin-audit"], ["/admin/settings", "admin-settings"], ["/admin/diagnostics", "admin-diagnostics"]]) {
await navigate(path);
await screenshot(name);
}
await navigate("/admin/vaults");
const vaultDetail = await evaluate("document.querySelector('a[href^=\"/admin/vault/\"]')?.getAttribute('href')");
if (!vaultDetail) throw new Error("vault detail link not found");
await navigate(vaultDetail);
await screenshot("admin-vault-detail");
await navigate("/admin/users?q=browser-smoke-user");
// Exercise the destructive confirmation dialog by blocking and unblocking the
// temporary user. requestSubmit is intentionally not used for this action.
async function toggleTemporaryUser() {
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); if (!row) throw new Error('temporary user row not found'); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'toggle-user'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/users");
await navigate("/admin/users?q=browser-smoke-user");
}
await toggleTemporaryUser();
await toggleTemporaryUser();
// A password reset is generated server-side, rendered once with no secret in
// the URL, and invalidates any prior user session. Keep it only in this test
// process so the subsequent user login exercises the generated credential.
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('browser-smoke-user')); const details = row.querySelector('details'); details.open = true; const form = [...row.querySelectorAll('form')].find((item) => item.querySelector('[name=action]')?.value === 'reset-user-password'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/password-result");
await screenshot("admin-password-result");
const generatedPassword = await evaluate("document.querySelector('.one-time-secret')?.textContent.trim()");
if (!generatedPassword || (await evaluate("location.href")).includes(generatedPassword)) throw new Error("one-time password result is missing or leaked into the URL");
await navigate("/admin/users?q=browser-smoke-user");
// Revoke the real temporary device through the browser UI and assert the
// modal path again.
await navigate("/admin/devices?q=Browser%20smoke");
await evaluate(`(() => { const row = [...document.querySelectorAll('tbody tr')].find((item) => item.textContent.includes('Browser smoke laptop')); if (!row) throw new Error('smoke device row not found'); const form = row.querySelector('form'); form.querySelector('[name=password]').value = 'browser-smoke-admin-password'; form.querySelector('button').click(); })()`);
await confirmDialog("/admin/devices");
// Server-side filters and HTML logout complete the browser path.
await navigate("/admin/audit?q=device");
await screenshot("admin-audit-filtered");
await evaluate("document.querySelector('form[action=\"/admin/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/admin/login", "admin logout");
await navigate("/login");
await submit('form[action="/login"]', { username: "browser-smoke-user", password: generatedPassword }, "/dashboard");
await screenshot("user-dashboard");
await evaluate("document.querySelector('form[action=\"/logout\"]').requestSubmit()");
await waitFor(async () => new URL(await evaluate("location.href")).pathname === "/login", "user logout");
socket.close();
console.log("interactive Chromium web smoke passed");

View File

@ -4,17 +4,21 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)"
PORT="47794"
cleanup() { kill "${SERVER_PID:-}" 2>/dev/null || true; rm -rf "$TMP"; }
DEBUG_PORT="9223"
cleanup() { kill "${BROWSER_PID:-}" 2>/dev/null || true; kill "${SERVER_PID:-}" 2>/dev/null || true; wait "${BROWSER_PID:-}" 2>/dev/null || true; wait "${SERVER_PID:-}" 2>/dev/null || true; if [[ "${KEEP_SMOKE_ARTIFACTS:-}" == "1" ]]; then printf 'web smoke artefacts retained at %s\n' "$TMP"; else rm -rf "$TMP"; fi; }
trap cleanup EXIT
printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass"
chmod 600 "$TMP/admin-pass"
(cd "$ROOT" && go run ./cmd/server --data "$TMP/data" --listen "127.0.0.1:$PORT" --admin-user admin --admin-pass-file "$TMP/admin-pass") >"$TMP/server.log" 2>&1 &
SERVER_PID=$!
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null && break; sleep 1; done
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 1; done
curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-en.png" --window-size=1440,900 "http://127.0.0.1:$PORT/" >/dev/null
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/login-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/admin-login.png" --window-size=1440,900 "http://127.0.0.1:$PORT/admin/login" >/dev/null
test -s "$TMP/public-en.png" && test -s "$TMP/login-mobile.png" && test -s "$TMP/admin-login.png"
echo "web browser smoke passed (screenshots were kept only for this run: $TMP)"
chromium --headless --no-sandbox --disable-gpu --window-size=1440,900 --remote-debugging-port="$DEBUG_PORT" --user-data-dir="$TMP/chromium" about:blank >"$TMP/chromium.log" 2>&1 &
BROWSER_PID=$!
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null 2>&1 && break; sleep 1; done
curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null
CHROME_DEBUG_URL="http://127.0.0.1:$DEBUG_PORT" node "$ROOT/scripts/smoke-web-browser.mjs" "http://127.0.0.1:$PORT" "$TMP"
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null 2>&1
test -s "$TMP/public-en.png" && test -s "$TMP/public-mobile.png" && test -s "$TMP/admin-dashboard.png" && test -s "$TMP/admin-settings.png"
echo "interactive web browser smoke passed (set KEEP_SMOKE_ARTIFACTS=1 to retain temporary screenshots)"