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 binary. It has no CDN, npm build, external font, or remote analytics
dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`, dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`,
and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user 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, `/admin/login` opens the administrator console. Its sidebar provides overview,
users, devices, vaults, storage, audit, SMTP settings, and diagnostics. User users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists
blocking is immediate; device revocation and SMTP changes require the current 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 administrator password again. Admin HTML is deliberately a normal server
rendered control plane; the existing `/admin/api/...` endpoints remain for rendered control plane; the existing `/admin/api/...` endpoints remain for
automation. automation.
The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its
values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then
`web.default_locale`, then English. The choice survives login and logout. `web.default_locale`, then English. The choice survives login and logout. A
Registration is controlled by `web.allow_registration`; when disabled the separate short-lived HttpOnly form token protects the language chooser and all
public registration page does not expose account creation. 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 The overview reports operational counts and readiness warnings. Vault details
view with aggregates only (never file payloads), safe retention cleanup, and a show only metadata (devices, sequence, operation count, activity, and blob
sanitized diagnostics download. General web settings are stored in the existing usage), never file contents or operation `payload_json`. Diagnostics download
`config.yml`; SMTP passwords are not returned to a browser form. 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 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 `frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The
console must still be deployed behind the HTTPS reverse proxy described below: console must still be deployed behind the HTTPS reverse proxy described below:
secure cookies are enabled when HTTPS is detected through a trusted proxy. 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`, Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound `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 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") s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login")
return return
} }
if !s.requirePublicWebMutation(w, r, "/admin/login") {
return
}
user := r.FormValue("username") user := r.FormValue("username")
pass := r.FormValue("password") pass := r.FormValue("password")
if !s.allowRate(w, r, "login", user) { if !s.allowWebRate(w, r, "login", user, "/admin/login") {
return return
} }
if !s.cfg.CheckAdmin(user, pass) { if !s.cfg.CheckAdmin(user, pass) {

View File

@ -15,7 +15,7 @@ import (
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") { 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 return
} }
jsonErr(w, 404, "not found") jsonErr(w, 404, "not found")

View File

@ -102,6 +102,9 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
} }
tokenStr = req.Token tokenStr = req.Token
} else if err := r.ParseForm(); err == nil { } else if err := r.ParseForm(); err == nil {
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
tokenStr = r.FormValue("token") tokenStr = r.FormValue("token")
} else { } else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form") 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") s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
return return
} }
if !s.requirePublicWebMutation(w, r, "/register") {
return
}
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password") username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
if username == "" || email == "" || password == "" { if username == "" || email == "" || password == "" {
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"}) s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
return return
} }
if !s.allowRate(w, r, "register", email) { if !s.allowWebRate(w, r, "register", email, "/register") {
return return
} }
if err := validatePassword(password); err != "" { 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") s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return return
} }
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if email == "" { if email == "" {
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"}) s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
return return
} }
if !s.allowRate(w, r, "forgot", email) { if !s.allowWebRate(w, r, "forgot", email, "/forgot") {
return return
} }
var userID string 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") s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return return
} }
if !s.requirePublicWebMutation(w, r, "/forgot") {
return
}
token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm") token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm")
if token == "" || password == "" || confirm == "" { if token == "" || password == "" || confirm == "" {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"}) s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"})
return return
} }
if !s.allowRate(w, r, "reset", "") { if !s.allowWebRate(w, r, "reset", "", "/forgot") {
return return
} }
if err := validatePassword(password); err != "" { 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") s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return return
} }
if !s.requirePublicWebMutation(w, r, "/login") {
return
}
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password") 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 return
} }
var userID, hash string var userID, hash string
@ -239,11 +251,20 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
var username, email string 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", "/") s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return 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 { if err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return return
@ -267,11 +288,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/") s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return return
} }
flash := r.URL.Query().Get("error") flash := r.URL.Query().Get("flash")
if flash != "error.invalidCredentials" { flashError := false
if flash == "" {
flash = r.URL.Query().Get("error")
flashError = flash != ""
}
if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" {
flash = "" 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) { 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 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") jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
} }
return 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 var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil { if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err) 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") s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
if formRequest { if formRequest {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther)
return return
} }
jsonOK(w, map[string]string{"status": "revoked"}) jsonOK(w, map[string]string{"status": "revoked"})

View File

@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
@ -65,32 +66,64 @@ func (s *Server) blobStorageWritable() bool {
// admin panel can expose operational data without coupling to templates. // admin panel can expose operational data without coupling to templates.
type ServerStats struct { type ServerStats struct {
Users int `json:"users"` Users int `json:"users"`
ActiveUsers int `json:"active_users"`
BlockedUsers int `json:"blocked_users"`
UnconfirmedUsers int `json:"unconfirmed_users"`
ActiveDevices int `json:"active_devices"` ActiveDevices int `json:"active_devices"`
RevokedDevices int `json:"revoked_devices"` RevokedDevices int `json:"revoked_devices"`
Vaults int `json:"vaults"` Vaults int `json:"vaults"`
Operations int `json:"operations"` 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"` DatabaseBytes int64 `json:"database_bytes"`
BlobBytes int64 `json:"blob_bytes"` BlobBytes int64 `json:"blob_bytes"`
LastSyncAt string `json:"last_sync_activity"` LastSyncAt string `json:"last_sync_activity"`
LastCleanupAt string `json:"last_cleanup_at"`
} }
func (s *Server) Stats(ctx context.Context) (ServerStats, error) { func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats var stats ServerStats
now := time.Now().UTC()
queries := []struct { queries := []struct {
query string query string
target *int target *int
}{ }{
{"SELECT COUNT(*) FROM server_users", &stats.Users}, {"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.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices}, {"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(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_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 { for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil { if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err 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 { if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err return ServerStats{}, err
} }
@ -112,5 +145,14 @@ func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
}); err != nil && !os.IsNotExist(err) { }); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", 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 return stats, nil
} }

View File

@ -77,6 +77,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Имя пользователя или email уже заняты.", "error.accountTaken": "Имя пользователя или email уже заняты.",
"error.emailRequired": "Введите email.", "error.emailRequired": "Введите email.",
"error.passwordMismatch": "Пароли не совпадают.", "error.passwordMismatch": "Пароли не совпадают.",
"error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.",
"error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.",
"error.notFound": "Страница не найдена.",
"error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.",
"home.unavailableTitle": "Сервер временно недоступен",
"home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.",
"error.invalidCredentials": "Неверное имя пользователя или пароль.", "error.invalidCredentials": "Неверное имя пользователя или пароль.",
"error.internal": "Внутренняя ошибка. Повторите попытку позже.", "error.internal": "Внутренняя ошибка. Повторите попытку позже.",
"admin.eyebrow": "Администрирование сервера", "admin.eyebrow": "Администрирование сервера",
@ -86,7 +92,7 @@ var _translations = map[string]map[string]string{
"admin.access": "Доступ", "admin.access": "Доступ",
"admin.activeDevices": "Активные устройства", "admin.activeDevices": "Активные устройства",
"admin.operations": "Операции", "admin.operations": "Операции",
"admin.vaults": "Vault", "admin.vaults": "Хранилища",
"admin.storage": "Хранилище", "admin.storage": "Хранилище",
"admin.audit": "Аудит", "admin.audit": "Аудит",
"admin.settings": "Настройки", "admin.settings": "Настройки",
@ -122,11 +128,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.", "admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.",
"user.account": "Моя учётная запись", "user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства", "user.devices": "Подключённые устройства",
"user.emailConfirmed": "Email подтверждён",
"user.emailUnconfirmed": "Email не подтверждён",
"user.filterDevices": "Поиск устройств",
"user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.",
"user.deviceRevoked": "Устройство отозвано.",
"user.noDevices": "Устройств пока нет", "user.noDevices": "Устройств пока нет",
"device.name": "Устройство", "device.name": "Устройство",
"device.vault": "Vault", "device.vault": "Хранилище",
"device.version": "Версия клиента", "device.version": "Версия клиента",
"device.lastSeen": "Последняя активность", "device.lastSeen": "Последняя активность",
"device.created": "Подключено",
"device.status": "Статус", "device.status": "Статус",
"device.active": "Активно", "device.active": "Активно",
"device.revoked": "Отозвано", "device.revoked": "Отозвано",
@ -227,10 +239,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Тест пройден", "admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.", "admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки 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": "Вы уверены?", "admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...", "common.loading": "Загрузка...",
"common.ok": "OK", "common.ok": "OK",
"common.error": "Ошибка", "common.error": "Ошибка",
"common.warning": "Предупреждения",
"admin.filterPlaceholder": "Поиск...", "admin.filterPlaceholder": "Поиск...",
"admin.email": "Email", "admin.email": "Email",
"admin.actions": "Действия", "admin.actions": "Действия",
@ -249,8 +322,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "Нет пользователей", "admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль", "admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ", "admin.resetPasswordMessage": "Новый пароль: ",
"admin.generatePassword": "Сгенерировать одноразовый пароль",
"admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.",
"admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.",
"admin.resetBtn": "Сбросить", "admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить", "admin.deleteUser": "Удалить",
"admin.deleteDevice": "Удалить устройство",
"admin.confirmUser": "Подтвердить email",
"admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.",
"admin.deleteUserMessage": "Удалить пользователя %s?", "admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить", "admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать", "admin.unblockUserTitle": "Разблокировать",
@ -320,6 +399,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Username or email is already in use.", "error.accountTaken": "Username or email is already in use.",
"error.emailRequired": "Enter an email address.", "error.emailRequired": "Enter an email address.",
"error.passwordMismatch": "Passwords do not match.", "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.invalidCredentials": "Invalid username or password.",
"error.internal": "Internal error. Please try again later.", "error.internal": "Internal error. Please try again later.",
"admin.eyebrow": "Server administration", "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.", "admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.",
"user.account": "My account", "user.account": "My account",
"user.devices": "Connected devices", "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", "user.noDevices": "No devices yet",
"device.name": "Device", "device.name": "Device",
"device.vault": "Vault", "device.vault": "Vault",
"device.version": "Client version", "device.version": "Client version",
"device.lastSeen": "Last activity", "device.lastSeen": "Last activity",
"device.created": "Connected",
"device.status": "Status", "device.status": "Status",
"device.active": "Active", "device.active": "Active",
"device.revoked": "Revoked", "device.revoked": "Revoked",
@ -470,10 +561,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Test passed", "admin.smtpPassed": "✓ Test passed",
"admin.smtpRequired": "Enter the SMTP server, port, and sender.", "admin.smtpRequired": "Enter the SMTP server, port, and sender.",
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.", "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?", "admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.ok": "OK", "common.ok": "OK",
"common.error": "Error", "common.error": "Error",
"common.warning": "Warnings",
"admin.filterPlaceholder": "Search...", "admin.filterPlaceholder": "Search...",
"admin.email": "Email", "admin.email": "Email",
"admin.actions": "Actions", "admin.actions": "Actions",
@ -492,8 +644,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "No users", "admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password", "admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New 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.resetBtn": "Reset",
"admin.deleteUser": "Delete", "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.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete", "admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock", "admin.unblockUserTitle": "Unblock",

View File

@ -18,12 +18,12 @@ var ratePolicies = map[string]RatePolicy{
"admin-reset": {Limit: 8, Window: time.Hour}, "admin-reset": {Limit: 8, Window: time.Hour},
} }
// allowRate applies an IP limit and, where a login/account is supplied, an // rateRetryAfter applies an IP limit and, where a login/account is supplied,
// additional bounded account bucket. It never logs submitted credentials. // an additional bounded account bucket. It never logs submitted credentials.
func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool { func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) {
policy, ok := ratePolicies[action] policy, ok := ratePolicies[action]
if !ok { if !ok {
return true return 0, true
} }
ip := s.clientIP(r) ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window) s.limiter.Cleanup(2 * policy.Window)
@ -40,14 +40,35 @@ func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, accou
if seconds < 1 { if seconds < 1 {
seconds = 1 seconds = 1
} }
w.Header().Set("Retry-After", strconvItoa(seconds))
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action) s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
return seconds, false
}
}
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") jsonErr(w, http.StatusTooManyRequests, "too many attempts")
return false 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 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 { func strconvItoa(value int) string {
if value == 0 { if value == 0 {

View File

@ -37,6 +37,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/admin", s.handleAdminRoot) s.mux.HandleFunc("/admin", s.handleAdminRoot)
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout) s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction) 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/dashboard", s.handleAdminWeb)
s.mux.HandleFunc("/admin/users", s.handleAdminWeb) s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb) s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)

View File

@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -22,6 +23,8 @@ type Server struct {
limiter *rateLimiter limiter *rateLimiter
web *webRenderer web *webRenderer
startedAt time.Time startedAt time.Time
secretMu sync.Mutex
webSecrets map[string]oneTimeWebSecret
} }
// Version and BuildCommit are assigned through -ldflags during release builds. // Version and BuildCommit are assigned through -ldflags during release builds.
@ -96,6 +99,7 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
limiter: newRateLimiter(nil), limiter: newRateLimiter(nil),
web: web, web: web,
startedAt: time.Now().UTC(), startedAt: time.Now().UTC(),
webSecrets: make(map[string]oneTimeWebSecret),
} }
s.mux = http.NewServeMux() s.mux = http.NewServeMux()
return s, nil return s, nil

View File

@ -643,9 +643,11 @@ func postWebReset(t *testing.T, s *Server, token, password string) *httptest.Res
"token": {token}, "token": {token},
"password": {password}, "password": {password},
"confirm": {password}, "confirm": {password},
"locale_csrf": {"test-public-csrf"},
} }
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode())) request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"})
response := httptest.NewRecorder() response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request) s.mux.ServeHTTP(response, request)
return response 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; } :focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.stack { display:grid; gap:1rem; } .stack { display:grid; gap:1rem; }
.stack label { display:grid; gap:.35rem; font-weight:650; } .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 { padding:.75rem; border-radius:8px; }
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; } .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; } .dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; }
.table-card { padding:1.25rem; } .table-card { padding:1.25rem; }
.table-scroll { overflow-x:auto; } .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,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; } 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 { 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.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; } .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; } .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; } .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; } .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; } .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; } .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; } } @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) { document.addEventListener("change", function (event) {
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit(); 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) { document.addEventListener("click", function (event) {
const button = event.target.closest("[data-confirm]"); 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 "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 "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 "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 "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 "forgot"}}{{template "layout" .}}{{end}}
{{define "content"}} {{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> <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}} {{end}}

View File

@ -5,6 +5,6 @@
<h1>{{t .Locale "home.heading"}}</h1> <h1>{{t .Locale "home.heading"}}</h1>
<p class="lead">{{t .Locale "home.description"}}</p> <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> <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> </section>
{{end}} {{end}}

View File

@ -17,7 +17,7 @@
</nav> </nav>
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}"> <form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
<input type="hidden" name="from" value="{{.CurrentURL}}"> <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> <label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
<select id="locale" name="locale" data-auto-submit> <select id="locale" name="locale" data-auto-submit>
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option> <option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
@ -29,6 +29,7 @@
</header> </header>
<main class="page-shell">{{template "content" .}}</main> <main class="page-shell">{{template "content" .}}</main>
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer> <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> <script src="/static/app.js" defer></script>
</body> </body>
</html> </html>

View File

@ -2,6 +2,6 @@
{{define "content"}} {{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1> <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}} {{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> <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}} {{end}}

View File

@ -2,6 +2,6 @@
{{define "content"}} {{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1> <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}} {{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> <p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
{{end}} {{end}}

View File

@ -2,5 +2,5 @@
{{define "content"}} {{define "content"}}
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1> <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}} {{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}} {{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 "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" "encoding/hex"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -13,6 +14,38 @@ import (
"golang.org/x/crypto/bcrypt" "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) { func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
methodNotAllowed(w, 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) 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) { func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
methodNotAllowed(w, http.MethodGet) methodNotAllowed(w, http.MethodGet)
@ -35,7 +90,7 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
return return
} }
var d webVaultDetail 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) jsonInternalError(w, err)
return return
} }
@ -44,7 +99,31 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
jsonInternalError(w, err) jsonInternalError(w, err)
return 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) { 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())} data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
switch page { 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": case "users":
data.List = webListFromRequest(r) data.List = webListFromRequest(r)
data.AdminUsers, data.List, err = s.webAdminUsers(data.List) 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) data.Audit, data.List, err = s.webAudit(data.List)
case "settings": case "settings":
data.SMTP = s.webSMTP() 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 { if err != nil {
log.Printf("admin %s: %v", page, err) log.Printf("admin %s: %v", page, err)
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard") s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
return return
} }
if page == "settings" { s.renderPage(w, r, "admin_"+page, data)
s.renderPage(w, r, "admin_settings", data)
return
}
s.renderPage(w, r, "admin", data)
} }
func webListFromRequest(r *http.Request) webList { 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 { if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 {
list.Page = value list.Page = value
} }
@ -198,9 +308,14 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
return nil, list, err return nil, list, err
} }
list = finishWebList(list, total) 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([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) 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 { if err != nil {
return nil, list, err return nil, list, err
} }
@ -209,7 +324,7 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
for rows.Next() { for rows.Next() {
var u webAdminUser var u webAdminUser
var confirmed, blocked int 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 return nil, list, err
} }
u.Confirmed = confirmed != 0 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) { func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) {
where := "" where := ""
args := []interface{}{} args := []interface{}{}
if list.Query != "" { addCondition := func(condition string, values ...interface{}) {
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]
if where == "" { if where == "" {
where = " WHERE " + condition where = " WHERE " + condition
} else { } else {
where += " AND " + condition 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 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 { 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 return nil, list, err
} }
list = finishWebList(list, total) 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([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) 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 { if err != nil {
return nil, list, err return nil, list, err
} }
@ -250,10 +382,13 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error
var out []webAdminDevice var out []webAdminDevice
for rows.Next() { for rows.Next() {
var d webAdminDevice var d webAdminDevice
var revoked string var revoked, prefix, suffix string
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil { 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 return nil, list, err
} }
if prefix != "" || suffix != "" {
d.TokenHint = prefix + "…" + suffix
}
d.Revoked = revoked != "" d.Revoked = revoked != ""
if d.LastSeen == "" { if d.LastSeen == "" {
d.LastSeen = "—" d.LastSeen = "—"
@ -283,19 +418,37 @@ func (s *Server) webVaults() ([]webVault, error) {
func (s *Server) webAudit(list webList) ([]webAudit, webList, error) { func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
where := "" where := ""
args := []interface{}{} args := []interface{}{}
addCondition := func(condition string, values ...interface{}) {
if where == "" {
where = " WHERE " + condition
} else {
where += " AND " + condition
}
args = append(args, values...)
}
if list.Query != "" { if list.Query != "" {
where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)"
like := "%" + list.Query + "%" 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 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 return nil, list, err
} }
list = finishWebList(list, total) list = finishWebList(list, total)
queryArgs := append([]interface{}{}, args...) queryArgs := append([]interface{}{}, args...)
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage) 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 { if err != nil {
return nil, list, err return nil, list, err
} }
@ -303,14 +456,25 @@ func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
var out []webAudit var out []webAudit
for rows.Next() { for rows.Next() {
var a webAudit 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 return nil, list, err
} }
a.Severity = auditSeverity(a.Event)
out = append(out, a) out = append(out, a)
} }
return out, list, rows.Err() 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 { 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")} 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" { if locale != "ru" && locale != "en" {
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.mu.Lock()
s.cfg.Web.DefaultLocale = locale s.cfg.Web.DefaultLocale = locale
s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on" s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on"
s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name")) s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name"))
s.cfg.PublicURL = publicURL
err := s.cfg.saveLocked() err := s.cfg.saveLocked()
s.cfg.mu.Unlock() s.cfg.mu.Unlock()
if err != nil { if err != nil {
@ -354,7 +527,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator") 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": case "toggle-user":
if !s.adminReauth(r, session.SubjectID) { if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") 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") s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) 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": case "reset-user-password":
if !s.adminReauth(r, session.SubjectID) { if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
return return
} }
id, password := r.FormValue("id"), r.FormValue("new_password") id := r.FormValue("id")
if err := validatePassword(password); err != "" { password, err := randomSecret(16)
s.renderWebError(w, r, http.StatusBadRequest, "error.passwordInvalid", "/admin/users") if err != nil {
jsonInternalError(w, err)
return return
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@ -446,7 +641,13 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator") 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": case "delete-user":
if !s.adminReauth(r, session.SubjectID) { if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users") 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") s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther) 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": case "smtp":
if !s.adminReauth(r, session.SubjectID) { if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings") 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 return
} }
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator") 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": case "cleanup":
if !s.adminReauth(r, session.SubjectID) { if !s.adminReauth(r, session.SubjectID) {
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage") 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 return
} }
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator") 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: default:
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard") s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
} }

View File

@ -1,11 +1,15 @@
package server package server
import ( import (
"crypto/subtle"
"net/http" "net/http"
"strings" "strings"
) )
const webLocaleCookieName = "verstak_locale" const (
webLocaleCookieName = "verstak_locale"
webLocaleCSRFCookieName = "verstak_locale_csrf"
)
func isSupportedWebLocale(locale string) bool { func isSupportedWebLocale(locale string) bool {
return locale == "en" || locale == "ru" 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, 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) { func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) {
for key := range _translations["en"] { for key := range _translations["en"] {
if _, ok := _translations["ru"][key]; !ok { 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) { func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) {
entries, err := fs.Glob(webFS, "web/templates/*.html") entries, err := fs.Glob(webFS, "web/templates/*.html")
if err != nil { 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) { func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
s, err := newTestServer(t) s, err := newTestServer(t)
if err != nil { 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) { func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) {
s, err := newTestServer(t) s, err := newTestServer(t)
if err != nil { if err != nil {
@ -223,8 +296,9 @@ func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
} }
defer s.Close() defer s.Close()
s.SetupRoutes() 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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder() res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req) s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" { if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
@ -243,8 +317,9 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
} }
defer s.Close() defer s.Close()
s.SetupRoutes() 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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
res := httptest.NewRecorder() res := httptest.NewRecorder()
s.Handler().ServeHTTP(res, req) s.Handler().ServeHTTP(res, req)
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" { 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) { func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
s, err := newTestServer(t) s, err := newTestServer(t)
if err != nil { 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) { func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.Web.DefaultLocale = "en" cfg.Web.DefaultLocale = "en"

View File

@ -5,6 +5,8 @@ import (
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -23,10 +25,15 @@ type webPage struct {
DefaultLocale string DefaultLocale string
Title string Title string
ServerName string ServerName string
PublicURL string
TrustedProxies string
Limits Limits
CurrentPath string CurrentPath string
CurrentURL string CurrentURL string
CSRF string CSRF string
LocaleCSRF string
Flash string Flash string
FlashError bool
AllowRegistration bool AllowRegistration bool
Version string Version string
BuildCommit string BuildCommit string
@ -37,9 +44,11 @@ type webPage struct {
FormAction string FormAction string
BackURL string BackURL string
Token string Token string
OneTimeSecret string
Admin bool Admin bool
UserName string UserName string
Email string Email string
UserConfirmed bool
Devices []webDevice Devices []webDevice
AdminPage string AdminPage string
Stats ServerStats Stats ServerStats
@ -47,7 +56,9 @@ type webPage struct {
AdminUsers []webAdminUser AdminUsers []webAdminUser
AdminDevices []webAdminDevice AdminDevices []webAdminDevice
Vaults []webVault Vaults []webVault
VaultDevices []webAdminDevice
Audit []webAudit Audit []webAudit
Warnings []string
SMTP webSMTP SMTP webSMTP
List webList List webList
VaultDetail webVaultDetail VaultDetail webVaultDetail
@ -56,10 +67,10 @@ type webPage struct {
type webAdminUser struct { type webAdminUser struct {
ID, Username, Email, CreatedAt, LastSeen string ID, Username, Email, CreatedAt, LastSeen string
Confirmed, Blocked bool Confirmed, Blocked bool
Devices int Devices, Vaults int
} }
type webAdminDevice struct { type webAdminDevice struct {
ID, Name, User, Vault, Version, LastSeen, CreatedAt string ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string
Revoked bool Revoked bool
} }
type webVault struct { type webVault struct {
@ -67,12 +78,11 @@ type webVault struct {
Devices, Operations int Devices, Operations int
LastActivity string 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 webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
type webList struct { type webList struct {
Query string Query, Status, Sort, User, Vault, Version, Event, Severity string
Status string
Page int Page int
PerPage int PerPage int
Total int Total int
@ -80,9 +90,27 @@ type webList struct {
Previous int Previous int
Next 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 { type webVaultDetail struct {
User, Vault, LastActivity string User, Vault, LastActivity string
Devices, Operations int Devices, Active, Revoked int
Operations, Sequence int
BlobBytes int64 BlobBytes int64
} }
@ -100,6 +128,12 @@ type webDevice struct {
func newWebRenderer() (*webRenderer, error) { func newWebRenderer() (*webRenderer, error) {
funcs := template.FuncMap{ 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 { "short": func(value string, length int) string {
if len(value) <= length || length < 5 { if len(value) <= length || length < 5 {
return value return value
@ -107,12 +141,12 @@ func newWebRenderer() (*webRenderer, error) {
return value[:length-1] + "…" 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 { if err != nil {
return nil, err return nil, err
} }
renderer := &webRenderer{templates: make(map[string]*template.Template)} 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() clone, err := layout.Clone()
if err != nil { if err != nil {
return nil, err return nil, err
@ -129,6 +163,74 @@ func newWebRenderer() (*webRenderer, error) {
return renderer, nil 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) { func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
s.renderPageStatus(w, r, page, data, http.StatusOK) 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.LocalePreference = s.webLocalePreference(r)
data.DefaultLocale = s.cfg.Web.DefaultLocale data.DefaultLocale = s.cfg.Web.DefaultLocale
data.ServerName = s.cfg.Web.ServerName 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.CurrentPath = r.URL.Path
data.CurrentURL = r.URL.RequestURI() data.CurrentURL = r.URL.RequestURI()
data.LocaleCSRF = s.webLocaleCSRF(w, r)
data.AllowRegistration = s.cfg.Web.AllowRegistration data.AllowRegistration = s.cfg.Web.AllowRegistration
data.Version = Version data.Version = Version
data.BuildCommit = BuildCommit data.BuildCommit = BuildCommit
@ -188,6 +294,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
return return
} }
status := s.healthStatus(r.Context()) 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}) 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"}) s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"})
return 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") locale := r.FormValue("locale")
if locale != "ru" && locale != "en" && locale != "system" { if locale != "ru" && locale != "en" && locale != "system" {
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)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)" TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)"
PORT="47794" 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 trap cleanup EXIT
printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass" printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass"
chmod 600 "$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 & (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=$! 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 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 --window-size=1440,900 --remote-debugging-port="$DEBUG_PORT" --user-data-dir="$TMP/chromium" about:blank >"$TMP/chromium.log" 2>&1 &
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/login-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null BROWSER_PID=$!
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 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
test -s "$TMP/public-en.png" && test -s "$TMP/login-mobile.png" && test -s "$TMP/admin-login.png" curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null
echo "web browser smoke passed (screenshots were kept only for this run: $TMP)" 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)"