diff --git a/README.md b/README.md
index 6c1c893..9a69e60 100644
--- a/README.md
+++ b/README.md
@@ -230,32 +230,61 @@ The server embeds its public, account, and administrator interface in the Go
binary. It has no CDN, npm build, external font, or remote analytics
dependency. `/` is a localized public page; `/login`, `/register`, `/forgot`,
and `/reset` use post/redirect/get flows. `/dashboard` lets a signed-in user
-review their own devices and revoke one only after entering their password.
+review and search only their own devices, see confirmation state and connection
+times, and revoke one only after entering their password. The reusable layout,
+templates, CSS, JavaScript, and local SVG live below `internal/server/web/` and
+are embedded with `go:embed`; there is no separate frontend build.
`/admin/login` opens the administrator console. Its sidebar provides overview,
-users, devices, vaults, storage, audit, SMTP settings, and diagnostics. User
-blocking is immediate; device revocation and SMTP changes require the current
+users, devices, vaults, storage, audit, SMTP settings, and diagnostics. Lists
+use bounded server-side search, filters, whitelisted sort order, and pagination.
+Administrators can create, edit, confirm, block, reset, and delete users; revoke
+devices; and permanently remove only a previously revoked device. A browser
+password reset generates a random password and exposes it once on a `no-store`
+page; it is never placed in the URL, audit log, cookies, or database plaintext.
+Destructive
+browser actions use the shared local confirmation dialog. Blocking, credential
+changes, device actions, cleanup, and SMTP changes require the current
administrator password again. Admin HTML is deliberately a normal server
rendered control plane; the existing `/admin/api/...` endpoints remain for
automation.
The locale resolver uses the `verstak_locale` HttpOnly/Lax cookie first. Its
values are `ru`, `en`, or `system`; `system` uses `Accept-Language`, then
-`web.default_locale`, then English. The choice survives login and logout.
-Registration is controlled by `web.allow_registration`; when disabled the
-public registration page does not expose account creation.
+`web.default_locale`, then English. The choice survives login and logout. A
+separate short-lived HttpOnly form token protects the language chooser and all
+anonymous browser POST forms; it is distinct from the server-side session CSRF
+token. Registration is controlled by `web.allow_registration`; when disabled
+the public registration page does not expose account creation.
-The admin console also provides bounded user/device/audit lists, a vault detail
-view with aggregates only (never file payloads), safe retention cleanup, and a
-sanitized diagnostics download. General web settings are stored in the existing
-`config.yml`; SMTP passwords are not returned to a browser form.
+The overview reports operational counts and readiness warnings. Vault details
+show only metadata (devices, sequence, operation count, activity, and blob
+usage), never file contents or operation `payload_json`. Diagnostics download
+is sanitized: it excludes paths, tokens, passwords, hashes, and payloads.
+General web settings are stored in the existing `config.yml`; public URL and
+registration policy can be changed there through the console, while transport
+limits remain read-only. SMTP passwords are never returned to a browser form.
All browser mutations use POST and validate a server-side session plus CSRF
-token. The server returns security headers including a restrictive CSP,
+token; anonymous forms use the separate public-form token described above.
+The server returns security headers including a restrictive CSP,
`frame-ancestors 'none'`, `nosniff`, and a same-origin referrer policy. The
console must still be deployed behind the HTTPS reverse proxy described below:
secure cookies are enabled when HTTPS is detected through a trusted proxy.
+Run the local interactive browser smoke (Chromium plus Node's built-in
+`undici`; no npm install) with:
+
+```bash
+./scripts/smoke-web.sh
+```
+
+It exercises language switching, admin login/navigation, temporary-user
+creation, block/unblock confirmation, device pairing/revocation, filtering,
+logout, and desktop/mobile screenshots. It starts an isolated temporary server
+and removes its data and screenshots on exit; it is not a reverse-proxy or
+production-deployment test.
+
Sync operations are generic records with `entity_type`, `entity_id`, `op_type`,
`payload_json`, `device_id`, and sequencing metadata. A pairing token is bound
to one user and vault. The server derives the stored device ID and operation
diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go
index baa9b5b..34702c3 100644
--- a/internal/server/handlers_admin.go
+++ b/internal/server/handlers_admin.go
@@ -22,9 +22,12 @@ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/login")
return
}
+ if !s.requirePublicWebMutation(w, r, "/admin/login") {
+ return
+ }
user := r.FormValue("username")
pass := r.FormValue("password")
- if !s.allowRate(w, r, "login", user) {
+ if !s.allowWebRate(w, r, "login", user, "/admin/login") {
return
}
if !s.cfg.CheckAdmin(user, pass) {
diff --git a/internal/server/handlers_api.go b/internal/server/handlers_api.go
index 01c65fd..a54c919 100644
--- a/internal/server/handlers_api.go
+++ b/internal/server/handlers_api.go
@@ -15,7 +15,7 @@ import (
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
- s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusNotFound)
+ s.renderPageStatus(w, r, "error", webPage{Title: "error.notFound", Heading: "error.notFound", Message: "error.notFound", BackURL: "/"}, http.StatusNotFound)
return
}
jsonErr(w, 404, "not found")
diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go
index 2e1216e..bd6bef3 100644
--- a/internal/server/handlers_auth.go
+++ b/internal/server/handlers_auth.go
@@ -102,6 +102,9 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) {
}
tokenStr = req.Token
} else if err := r.ParseForm(); err == nil {
+ if !s.requirePublicWebMutation(w, r, "/login") {
+ return
+ }
tokenStr = r.FormValue("token")
} else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "invalid form")
diff --git a/internal/server/handlers_web_user.go b/internal/server/handlers_web_user.go
index 4466df6..51e1be6 100644
--- a/internal/server/handlers_web_user.go
+++ b/internal/server/handlers_web_user.go
@@ -37,12 +37,15 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
return
}
+ if !s.requirePublicWebMutation(w, r, "/register") {
+ return
+ }
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
if username == "" || email == "" || password == "" {
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
return
}
- if !s.allowRate(w, r, "register", email) {
+ if !s.allowWebRate(w, r, "register", email, "/register") {
return
}
if err := validatePassword(password); err != "" {
@@ -104,12 +107,15 @@ func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
+ if !s.requirePublicWebMutation(w, r, "/forgot") {
+ return
+ }
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if email == "" {
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
return
}
- if !s.allowRate(w, r, "forgot", email) {
+ if !s.allowWebRate(w, r, "forgot", email, "/forgot") {
return
}
var userID string
@@ -163,12 +169,15 @@ func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
return
}
+ if !s.requirePublicWebMutation(w, r, "/forgot") {
+ return
+ }
token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm")
if token == "" || password == "" || confirm == "" {
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"})
return
}
- if !s.allowRate(w, r, "reset", "") {
+ if !s.allowWebRate(w, r, "reset", "", "/forgot") {
return
}
if err := validatePassword(password); err != "" {
@@ -205,8 +214,11 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
return
}
+ if !s.requirePublicWebMutation(w, r, "/login") {
+ return
+ }
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password")
- if !s.allowRate(w, r, "login", login) {
+ if !s.allowWebRate(w, r, "login", login, "/login") {
return
}
var userID, hash string
@@ -239,11 +251,20 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
return
}
var username, email string
- if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil {
+ var confirmed int
+ if err := s.db.QueryRow("SELECT username, email, confirmed FROM server_users WHERE id=?", userID).Scan(&username, &email, &confirmed); err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
- rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id WHERE ud.user_id=? ORDER BY d.created_at DESC`, userID)
+ query := strings.TrimSpace(r.URL.Query().Get("q"))
+ where := " WHERE ud.user_id=?"
+ args := []interface{}{userID}
+ if query != "" {
+ like := "%" + query + "%"
+ where += " AND (d.name LIKE ? OR d.vault_id LIKE ? OR d.client_version LIKE ?)"
+ args = append(args, like, like, like)
+ }
+ rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id`+where+` ORDER BY d.created_at DESC`, args...)
if err != nil {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
@@ -267,11 +288,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
return
}
- flash := r.URL.Query().Get("error")
- if flash != "error.invalidCredentials" {
+ flash := r.URL.Query().Get("flash")
+ flashError := false
+ if flash == "" {
+ flash = r.URL.Query().Get("error")
+ flashError = flash != ""
+ }
+ if flash != "error.invalidCredentials" && flash != "user.deviceRevoked" {
flash = ""
}
- s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash})
+ s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, UserConfirmed: confirmed != 0, Devices: devices, Flash: flash, FlashError: flashError, List: webList{Query: query}})
}
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
@@ -353,12 +379,21 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
password = req.Password
}
- if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
- if password == "" {
+ if password == "" {
+ if formRequest {
+ http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
+ } else {
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
}
return
}
+ if formRequest {
+ if !s.allowWebRate(w, r, "auth-test", session.SubjectID, "/dashboard") {
+ return
+ }
+ } else if !s.allowRate(w, r, "auth-test", session.SubjectID) {
+ return
+ }
var hash string
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
jsonInternalError(w, err)
@@ -387,7 +422,7 @@ func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Reques
}
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
if formRequest {
- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
+ http.Redirect(w, r, "/dashboard?flash=user.deviceRevoked", http.StatusSeeOther)
return
}
jsonOK(w, map[string]string{"status": "revoked"})
diff --git a/internal/server/health.go b/internal/server/health.go
index 5399c6c..fc05158 100644
--- a/internal/server/health.go
+++ b/internal/server/health.go
@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "strings"
"time"
)
@@ -64,33 +65,65 @@ func (s *Server) blobStorageWritable() bool {
// ServerStats is intentionally independent from the web UI so a future
// admin panel can expose operational data without coupling to templates.
type ServerStats struct {
- Users int `json:"users"`
- ActiveDevices int `json:"active_devices"`
- RevokedDevices int `json:"revoked_devices"`
- Vaults int `json:"vaults"`
- Operations int `json:"operations"`
- DatabaseBytes int64 `json:"database_bytes"`
- BlobBytes int64 `json:"blob_bytes"`
- LastSyncAt string `json:"last_sync_activity"`
+ Users int `json:"users"`
+ ActiveUsers int `json:"active_users"`
+ BlockedUsers int `json:"blocked_users"`
+ UnconfirmedUsers int `json:"unconfirmed_users"`
+ ActiveDevices int `json:"active_devices"`
+ RevokedDevices int `json:"revoked_devices"`
+ Vaults int `json:"vaults"`
+ Operations int `json:"operations"`
+ Operations24h int `json:"operations_24h"`
+ Blobs int `json:"blobs"`
+ BlobReferences int `json:"blob_references"`
+ OrphanBlobs int `json:"orphan_blobs"`
+ TempUploads int `json:"temp_uploads"`
+ ExpiredSessions int `json:"expired_sessions"`
+ ExpiredTokens int `json:"expired_email_tokens"`
+ AuditEvents int `json:"audit_events"`
+ DatabaseBytes int64 `json:"database_bytes"`
+ BlobBytes int64 `json:"blob_bytes"`
+ LastSyncAt string `json:"last_sync_activity"`
+ LastCleanupAt string `json:"last_cleanup_at"`
}
func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
var stats ServerStats
+ now := time.Now().UTC()
queries := []struct {
query string
target *int
}{
{"SELECT COUNT(*) FROM server_users", &stats.Users},
+ {"SELECT COUNT(*) FROM server_users WHERE confirmed=1 AND blocked=0", &stats.ActiveUsers},
+ {"SELECT COUNT(*) FROM server_users WHERE blocked=1", &stats.BlockedUsers},
+ {"SELECT COUNT(*) FROM server_users WHERE confirmed=0", &stats.UnconfirmedUsers},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') = ''", &stats.ActiveDevices},
{"SELECT COUNT(*) FROM server_devices WHERE COALESCE(revoked_at, '') != ''", &stats.RevokedDevices},
{"SELECT COUNT(DISTINCT user_id || ':' || vault_id) FROM server_devices WHERE COALESCE(user_id,'') != '' AND COALESCE(vault_id,'') != ''", &stats.Vaults},
{"SELECT COUNT(*) FROM server_ops", &stats.Operations},
+ {"SELECT COUNT(*) FROM server_blobs", &stats.Blobs},
+ {"SELECT COUNT(*) FROM server_blob_refs", &stats.BlobReferences},
+ {"SELECT COUNT(*) FROM server_blobs b WHERE NOT EXISTS (SELECT 1 FROM server_blob_refs r WHERE r.sha256=b.sha256)", &stats.OrphanBlobs},
+ {"SELECT COUNT(*) FROM server_audit_log", &stats.AuditEvents},
}
for _, query := range queries {
if err := s.db.QueryRowContext(ctx, query.query).Scan(query.target); err != nil {
return ServerStats{}, err
}
}
+ if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_ops WHERE created_at >= ?", now.Add(-24*time.Hour).Format(time.RFC3339)).Scan(&stats.Operations24h); err != nil {
+ return ServerStats{}, err
+ }
+ if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_sessions WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredSessions); err != nil {
+ return ServerStats{}, err
+ }
+ if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM server_email_tokens WHERE expires_at <= ?", now.Format(time.RFC3339)).Scan(&stats.ExpiredTokens); err != nil {
+ return ServerStats{}, err
+ }
+ if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(created_at),'') FROM server_audit_log WHERE event_type='retention_cleanup'").Scan(&stats.LastCleanupAt); err != nil {
+ return ServerStats{}, err
+ }
if err := s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(last_seen), '') FROM server_devices").Scan(&stats.LastSyncAt); err != nil {
return ServerStats{}, err
}
@@ -112,5 +145,14 @@ func (s *Server) Stats(ctx context.Context) (ServerStats, error) {
}); err != nil && !os.IsNotExist(err) {
return ServerStats{}, fmt.Errorf("blob storage stats: %w", err)
}
+ if entries, err := os.ReadDir(s.blobsDir); err == nil {
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), ".upload-") {
+ stats.TempUploads++
+ }
+ }
+ } else if !os.IsNotExist(err) {
+ return ServerStats{}, fmt.Errorf("temporary upload stats: %w", err)
+ }
return stats, nil
}
diff --git a/internal/server/locale.go b/internal/server/locale.go
index 9ba4227..c6fe5e2 100644
--- a/internal/server/locale.go
+++ b/internal/server/locale.go
@@ -77,6 +77,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Имя пользователя или email уже заняты.",
"error.emailRequired": "Введите email.",
"error.passwordMismatch": "Пароли не совпадают.",
+ "error.invalidPublicURL": "Укажите корректный абсолютный public URL с http или https.",
+ "error.rateLimited": "Слишком много попыток. Подождите и повторите запрос.",
+ "error.notFound": "Страница не найдена.",
+ "error.deviceMustBeRevoked": "Удалять можно только предварительно отозванное устройство.",
+ "home.unavailableTitle": "Сервер временно недоступен",
+ "home.unavailableMessage": "Сервер ещё не готов обслуживать запросы. Повторите попытку позже.",
"error.invalidCredentials": "Неверное имя пользователя или пароль.",
"error.internal": "Внутренняя ошибка. Повторите попытку позже.",
"admin.eyebrow": "Администрирование сервера",
@@ -86,7 +92,7 @@ var _translations = map[string]map[string]string{
"admin.access": "Доступ",
"admin.activeDevices": "Активные устройства",
"admin.operations": "Операции",
- "admin.vaults": "Vault",
+ "admin.vaults": "Хранилища",
"admin.storage": "Хранилище",
"admin.audit": "Аудит",
"admin.settings": "Настройки",
@@ -122,11 +128,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP настраивается отдельно и пароль никогда не возвращается в форму.",
"user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства",
+ "user.emailConfirmed": "Email подтверждён",
+ "user.emailUnconfirmed": "Email не подтверждён",
+ "user.filterDevices": "Поиск устройств",
+ "user.connectInstruction": "Подключите новое устройство через приложение Verstak: откройте настройки синхронизации и выполните pairing с этим сервером.",
+ "user.deviceRevoked": "Устройство отозвано.",
"user.noDevices": "Устройств пока нет",
"device.name": "Устройство",
- "device.vault": "Vault",
+ "device.vault": "Хранилище",
"device.version": "Версия клиента",
"device.lastSeen": "Последняя активность",
+ "device.created": "Подключено",
"device.status": "Статус",
"device.active": "Активно",
"device.revoked": "Отозвано",
@@ -227,10 +239,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Тест пройден",
"admin.smtpRequired": "Укажите SMTP-сервер, порт и отправителя.",
"admin.smtpTestFailed": "Не удалось проверить настройки SMTP. Повторите попытку.",
+ "admin.settingsSaved": "Настройки сервера сохранены.",
+ "admin.smtpSaved": "Настройки SMTP сохранены.",
+ "admin.smtpStartTLS": "STARTTLS",
+ "admin.smtpTLS": "TLS",
+ "admin.warningReadiness": "Один или несколько компонентов сервера не готовы к работе.",
+ "admin.warningSMTP": "SMTP не настроен: письма подтверждения и сброса пароля не будут отправляться.",
+ "admin.warningOperations": "Журнал операций быстро растёт. Не удаляйте его без checkpoint-механизма.",
+ "admin.ops24h": "за 24 часа",
+ "admin.sort": "Сортировка",
+ "admin.created": "Создан",
+ "admin.ip": "IP",
+ "admin.message": "Сообщение",
+ "admin.severity": "Важность",
+ "admin.info": "Информация",
+ "admin.warningLevel": "Предупреждение",
+ "admin.errorLevel": "Ошибка",
+ "admin.publicURL": "Публичный URL",
+ "admin.saveSettings": "Сохранить настройки",
+ "admin.trustedProxies": "Доверенные reverse proxy",
+ "admin.network": "Сеть",
+ "admin.limits": "Лимиты и retention",
+ "admin.readOnlyConfig": "Лимиты задаются конфигурацией сервера и доступны здесь только для просмотра.",
+ "admin.maxJSONBody": "Максимальный JSON body",
+ "admin.maxPushOperations": "Операций в push",
+ "admin.maxPullPage": "Операций в pull page",
+ "admin.maxBlobBytes": "Максимальный размер blob",
+ "admin.maxVaultBlobBytes": "Квота blob vault",
+ "admin.maxUserBlobBytes": "Квота blob пользователя",
+ "admin.sequence": "Последняя sequence",
+ "admin.refresh": "Обновить",
+ "admin.copyDiagnostics": "Скопировать диагностику",
+ "admin.copied": "Скопировано",
+ "admin.blobReferences": "Ссылки на blobs",
+ "admin.orphanBlobs": "Blob без ссылок",
+ "admin.tempUploads": "Временные загрузки",
+ "admin.expiredSessions": "Истёкшие сессии",
+ "admin.expiredTokens": "Истёкшие email-токены",
+ "admin.lastCleanup": "Последняя очистка",
+ "admin.cleanupDone": "Безопасная очистка завершена.",
+ "audit.other": "Другое событие",
+ "audit.deviceAuthFailed": "Ошибка авторизации устройства",
+ "audit.devicePaired": "Устройство подключено",
+ "audit.deviceRevoked": "Устройство отозвано",
+ "audit.deviceDeleted": "Отозванное устройство удалено",
+ "audit.rateLimited": "Сработало ограничение попыток",
+ "audit.retentionCleanup": "Выполнена безопасная очистка",
+ "audit.smtpSettingsUpdated": "Настройки SMTP изменены",
+ "audit.smtpTestFailed": "Проверка SMTP не удалась",
+ "audit.smtpTestPassed": "Проверка SMTP прошла",
+ "audit.userBlockChanged": "Статус блокировки пользователя изменён",
+ "audit.userConfirmed": "Email пользователя подтверждён",
+ "audit.userCreated": "Пользователь создан",
+ "audit.userDeleted": "Пользователь удалён",
+ "audit.userPasswordReset": "Пароль пользователя сброшен",
+ "audit.userUpdated": "Пользователь изменён",
+ "audit.webSettingsUpdated": "Общие настройки изменены",
+ "status.ok": "Работает",
+ "status.degraded": "Требует внимания",
+ "status.available": "Доступно",
+ "status.unavailable": "Недоступно",
"admin.revokeConfirm": "Вы уверены?",
"common.loading": "Загрузка...",
"common.ok": "OK",
"common.error": "Ошибка",
+ "common.warning": "Предупреждения",
"admin.filterPlaceholder": "Поиск...",
"admin.email": "Email",
"admin.actions": "Действия",
@@ -249,8 +322,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "Нет пользователей",
"admin.resetPasswordConfirm": "Сбросить пароль",
"admin.resetPasswordMessage": "Новый пароль: ",
+ "admin.generatePassword": "Сгенерировать одноразовый пароль",
+ "admin.oneTimePasswordNotice": "Сохраните этот пароль сейчас: он будет показан только один раз.",
+ "admin.oneTimePasswordHint": "После ухода со страницы получить пароль повторно нельзя. Пользователь должен сменить его после входа.",
"admin.resetBtn": "Сбросить",
"admin.deleteUser": "Удалить",
+ "admin.deleteDevice": "Удалить устройство",
+ "admin.confirmUser": "Подтвердить email",
+ "admin.deleteUserConfirm": "Удалить пользователя и все связанные устройства, vault-связи, blobs и операции? Это действие нельзя отменить.",
"admin.deleteUserMessage": "Удалить пользователя %s?",
"admin.deleteBtn": "Удалить",
"admin.unblockUserTitle": "Разблокировать",
@@ -320,6 +399,12 @@ var _translations = map[string]map[string]string{
"error.accountTaken": "Username or email is already in use.",
"error.emailRequired": "Enter an email address.",
"error.passwordMismatch": "Passwords do not match.",
+ "error.invalidPublicURL": "Enter a valid absolute public URL using http or https.",
+ "error.rateLimited": "Too many attempts. Wait and try again.",
+ "error.notFound": "Page not found.",
+ "error.deviceMustBeRevoked": "Only a revoked device can be deleted.",
+ "home.unavailableTitle": "Server is temporarily unavailable",
+ "home.unavailableMessage": "The server is not ready to serve requests yet. Try again later.",
"error.invalidCredentials": "Invalid username or password.",
"error.internal": "Internal error. Please try again later.",
"admin.eyebrow": "Server administration",
@@ -365,11 +450,17 @@ var _translations = map[string]map[string]string{
"admin.smtpConfigured": "SMTP is configured separately and its password is never returned to a form.",
"user.account": "My account",
"user.devices": "Connected devices",
+ "user.emailConfirmed": "Email confirmed",
+ "user.emailUnconfirmed": "Email not confirmed",
+ "user.filterDevices": "Search devices",
+ "user.connectInstruction": "Connect another device in Verstak: open sync settings and pair it with this server.",
+ "user.deviceRevoked": "Device revoked.",
"user.noDevices": "No devices yet",
"device.name": "Device",
"device.vault": "Vault",
"device.version": "Client version",
"device.lastSeen": "Last activity",
+ "device.created": "Connected",
"device.status": "Status",
"device.active": "Active",
"device.revoked": "Revoked",
@@ -470,10 +561,71 @@ var _translations = map[string]map[string]string{
"admin.smtpPassed": "✓ Test passed",
"admin.smtpRequired": "Enter the SMTP server, port, and sender.",
"admin.smtpTestFailed": "Could not test the SMTP settings. Please try again.",
+ "admin.settingsSaved": "Server settings saved.",
+ "admin.smtpSaved": "SMTP settings saved.",
+ "admin.smtpStartTLS": "STARTTLS",
+ "admin.smtpTLS": "TLS",
+ "admin.warningReadiness": "One or more server components are not ready.",
+ "admin.warningSMTP": "SMTP is not configured: confirmation and password-reset emails will not be sent.",
+ "admin.warningOperations": "The operation log is growing quickly. Do not prune it without a checkpoint mechanism.",
+ "admin.ops24h": "in the last 24 hours",
+ "admin.sort": "Sort",
+ "admin.created": "Created",
+ "admin.ip": "IP",
+ "admin.message": "Message",
+ "admin.severity": "Severity",
+ "admin.info": "Info",
+ "admin.warningLevel": "Warning",
+ "admin.errorLevel": "Error",
+ "admin.publicURL": "Public URL",
+ "admin.saveSettings": "Save settings",
+ "admin.trustedProxies": "Trusted reverse proxies",
+ "admin.network": "Network",
+ "admin.limits": "Limits and retention",
+ "admin.readOnlyConfig": "Limits are configured on the server and are read-only here.",
+ "admin.maxJSONBody": "Maximum JSON body",
+ "admin.maxPushOperations": "Operations per push",
+ "admin.maxPullPage": "Operations per pull page",
+ "admin.maxBlobBytes": "Maximum blob size",
+ "admin.maxVaultBlobBytes": "Vault blob quota",
+ "admin.maxUserBlobBytes": "User blob quota",
+ "admin.sequence": "Latest sequence",
+ "admin.refresh": "Refresh",
+ "admin.copyDiagnostics": "Copy diagnostics",
+ "admin.copied": "Copied",
+ "admin.blobReferences": "Blob references",
+ "admin.orphanBlobs": "Unreferenced blobs",
+ "admin.tempUploads": "Temporary uploads",
+ "admin.expiredSessions": "Expired sessions",
+ "admin.expiredTokens": "Expired email tokens",
+ "admin.lastCleanup": "Last cleanup",
+ "admin.cleanupDone": "Safe cleanup completed.",
+ "audit.other": "Other event",
+ "audit.deviceAuthFailed": "Device authentication failed",
+ "audit.devicePaired": "Device paired",
+ "audit.deviceRevoked": "Device revoked",
+ "audit.deviceDeleted": "Revoked device deleted",
+ "audit.rateLimited": "Rate limit triggered",
+ "audit.retentionCleanup": "Safe cleanup completed",
+ "audit.smtpSettingsUpdated": "SMTP settings updated",
+ "audit.smtpTestFailed": "SMTP test failed",
+ "audit.smtpTestPassed": "SMTP test passed",
+ "audit.userBlockChanged": "User block status changed",
+ "audit.userConfirmed": "User email confirmed",
+ "audit.userCreated": "User created",
+ "audit.userDeleted": "User deleted",
+ "audit.userPasswordReset": "User password reset",
+ "audit.userUpdated": "User updated",
+ "audit.webSettingsUpdated": "General settings updated",
+ "status.ok": "Operational",
+ "status.degraded": "Needs attention",
+ "status.available": "Available",
+ "status.unavailable": "Unavailable",
"admin.revokeConfirm": "Are you sure?",
"common.loading": "Loading...",
"common.ok": "OK",
"common.error": "Error",
+ "common.warning": "Warnings",
"admin.filterPlaceholder": "Search...",
"admin.email": "Email",
"admin.actions": "Actions",
@@ -492,8 +644,14 @@ var _translations = map[string]map[string]string{
"admin.noUsers": "No users",
"admin.resetPasswordConfirm": "Reset Password",
"admin.resetPasswordMessage": "New password: ",
+ "admin.generatePassword": "Generate one-time password",
+ "admin.oneTimePasswordNotice": "Save this password now: it is shown only once.",
+ "admin.oneTimePasswordHint": "It cannot be retrieved after leaving this page. The user should change it after signing in.",
"admin.resetBtn": "Reset",
"admin.deleteUser": "Delete",
+ "admin.deleteDevice": "Delete device",
+ "admin.confirmUser": "Confirm email",
+ "admin.deleteUserConfirm": "Delete this user and all related devices, vault associations, blobs, and operations? This cannot be undone.",
"admin.deleteUserMessage": "Delete user %s?",
"admin.deleteBtn": "Delete",
"admin.unblockUserTitle": "Unblock",
diff --git a/internal/server/rate.go b/internal/server/rate.go
index c41b7f8..b8702fe 100644
--- a/internal/server/rate.go
+++ b/internal/server/rate.go
@@ -18,12 +18,12 @@ var ratePolicies = map[string]RatePolicy{
"admin-reset": {Limit: 8, Window: time.Hour},
}
-// allowRate applies an IP limit and, where a login/account is supplied, an
-// additional bounded account bucket. It never logs submitted credentials.
-func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
+// rateRetryAfter applies an IP limit and, where a login/account is supplied,
+// an additional bounded account bucket. It never logs submitted credentials.
+func (s *Server) rateRetryAfter(r *http.Request, action, account string) (int, bool) {
policy, ok := ratePolicies[action]
if !ok {
- return true
+ return 0, true
}
ip := s.clientIP(r)
s.limiter.Cleanup(2 * policy.Window)
@@ -40,13 +40,34 @@ func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, accou
if seconds < 1 {
seconds = 1
}
- w.Header().Set("Retry-After", strconvItoa(seconds))
s.auditLog("rate_limit_exceeded", "", "", ip, "rate limit: "+action)
- jsonErr(w, http.StatusTooManyRequests, "too many attempts")
- return false
+ return seconds, false
}
}
- return true
+ return 0, true
+}
+
+// allowRate writes API-compatible JSON for transport endpoints.
+func (s *Server) allowRate(w http.ResponseWriter, r *http.Request, action, account string) bool {
+ retryAfter, allowed := s.rateRetryAfter(r, action, account)
+ if allowed {
+ return true
+ }
+ w.Header().Set("Retry-After", strconvItoa(retryAfter))
+ jsonErr(w, http.StatusTooManyRequests, "too many attempts")
+ return false
+}
+
+// allowWebRate is the HTML counterpart of allowRate. Browser forms receive a
+// localized error page instead of a machine-readable API error.
+func (s *Server) allowWebRate(w http.ResponseWriter, r *http.Request, action, account, back string) bool {
+ retryAfter, allowed := s.rateRetryAfter(r, action, account)
+ if allowed {
+ return true
+ }
+ w.Header().Set("Retry-After", strconvItoa(retryAfter))
+ s.renderWebError(w, r, http.StatusTooManyRequests, "error.rateLimited", back)
+ return false
}
func strconvItoa(value int) string {
diff --git a/internal/server/routes.go b/internal/server/routes.go
index e8dd06c..0fb962f 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -37,6 +37,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/admin", s.handleAdminRoot)
s.mux.HandleFunc("/admin/logout", s.handleAdminWebLogout)
s.mux.HandleFunc("/admin/action", s.handleAdminWebAction)
+ s.mux.HandleFunc("/admin/password-result", s.handleAdminPasswordResult)
s.mux.HandleFunc("/admin/dashboard", s.handleAdminWeb)
s.mux.HandleFunc("/admin/users", s.handleAdminWeb)
s.mux.HandleFunc("/admin/create-user", s.handleAdminCreateUserWeb)
diff --git a/internal/server/server.go b/internal/server/server.go
index c6c15d0..92e956f 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -8,20 +8,23 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"time"
_ "github.com/mattn/go-sqlite3"
)
type Server struct {
- db *sql.DB
- dbPath string
- cfg *Config
- blobsDir string
- mux *http.ServeMux
- limiter *rateLimiter
- web *webRenderer
- startedAt time.Time
+ db *sql.DB
+ dbPath string
+ cfg *Config
+ blobsDir string
+ mux *http.ServeMux
+ limiter *rateLimiter
+ web *webRenderer
+ startedAt time.Time
+ secretMu sync.Mutex
+ webSecrets map[string]oneTimeWebSecret
}
// Version and BuildCommit are assigned through -ldflags during release builds.
@@ -89,13 +92,14 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
return nil, fmt.Errorf("web templates: %w", err)
}
s := &Server{
- db: db,
- dbPath: dbPath,
- cfg: cfg,
- blobsDir: blobsDir,
- limiter: newRateLimiter(nil),
- web: web,
- startedAt: time.Now().UTC(),
+ db: db,
+ dbPath: dbPath,
+ cfg: cfg,
+ blobsDir: blobsDir,
+ limiter: newRateLimiter(nil),
+ web: web,
+ startedAt: time.Now().UTC(),
+ webSecrets: make(map[string]oneTimeWebSecret),
}
s.mux = http.NewServeMux()
return s, nil
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index c23ffab..e6ab7db 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -640,12 +640,14 @@ func pairSyncDevice(t *testing.T, serverURL, username, password, vaultID string)
func postWebReset(t *testing.T, s *Server, token, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
- "token": {token},
- "password": {password},
- "confirm": {password},
+ "token": {token},
+ "password": {password},
+ "confirm": {password},
+ "locale_csrf": {"test-public-csrf"},
}
request := httptest.NewRequest(http.MethodPost, "/reset", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "test-public-csrf"})
response := httptest.NewRecorder()
s.mux.ServeHTTP(response, request)
return response
diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css
index 74bd2f4..6427625 100644
--- a/internal/server/web/static/app.css
+++ b/internal/server/web/static/app.css
@@ -26,17 +26,22 @@ body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.stack { display:grid; gap:1rem; }
.stack label { display:grid; gap:.35rem; font-weight:650; }
-.stack input,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
+.stack input:not([type="checkbox"]),.stack select,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
+.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); }
.flash { padding:.75rem; border-radius:8px; }
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; }
+.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; }
+.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; }
+.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; }
.dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; }
.table-card { padding:1.25rem; }
.table-scroll { overflow-x:auto; }
-table { width:100%; border-collapse:collapse; }
+table { width:100%; min-width:720px; border-collapse:collapse; }
th,td { padding:.75rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; }
.badge { display:inline-flex; padding:.2rem .55rem; border-radius:999px; font-size:.8rem; font-weight:700; }
.badge.ok { background:#173d36; color:#91ecd9; }.badge.danger { background:#4c2529; color:#ffd9d9; }
+.badge.warning { background:#4b421d; color:#ffed9a; }
.inline-form { display:flex; min-width:250px; gap:.4rem; }.inline-form input { min-width:120px; }
.link-button { padding:0; border:0; background:transparent; color:var(--muted); font:inherit; cursor:pointer; }
.admin-shell { display:grid; grid-template-columns:220px minmax(0,1fr); gap:1.5rem; }
@@ -49,4 +54,5 @@ details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.c
.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; }
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
+.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin:0; }
@media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } }
diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js
index a0bb498..460e5ab 100644
--- a/internal/server/web/static/app.js
+++ b/internal/server/web/static/app.js
@@ -1,7 +1,38 @@
document.addEventListener("change", function (event) {
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit();
});
+
+const confirmationDialog = document.getElementById("confirm-dialog");
+let confirmationForm = null;
+let confirmationButton = null;
document.addEventListener("click", function (event) {
const button = event.target.closest("[data-confirm]");
- if (button && !window.confirm(button.dataset.confirm)) event.preventDefault();
+ if (!button || !button.form || !confirmationDialog) return;
+ event.preventDefault();
+ confirmationForm = button.form;
+ confirmationButton = button;
+ document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm;
+ confirmationDialog.showModal();
+});
+if (confirmationDialog) {
+ confirmationDialog.addEventListener("close", function () {
+ if (confirmationDialog.returnValue === "confirm" && confirmationForm) {
+ confirmationForm.requestSubmit(confirmationButton);
+ }
+ confirmationForm = null;
+ confirmationButton = null;
+ });
+}
+
+document.addEventListener("click", async function (event) {
+ const button = event.target.closest("[data-copy-diagnostics]");
+ if (!button || !navigator.clipboard) return;
+ try {
+ const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" });
+ if (!response.ok) return;
+ await navigator.clipboard.writeText(await response.text());
+ button.textContent = button.dataset.copiedLabel;
+ } catch (_) {
+ // The downloadable JSON link remains available when clipboard access is unavailable.
+ }
});
diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html
deleted file mode 100644
index 7bef216..0000000
--- a/internal/server/web/templates/admin.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{{define "admin"}}{{template "layout" .}}{{end}}
-{{define "content"}}
- {{t .Locale "admin.overview"}} {{t .Locale "admin.access"}} {{t .Locale "admin.access"}} {{t .Locale "admin.storage"}} {{t .Locale "admin.storage"}} {{t .Locale "admin.retentionNote"}} {{t .Locale "admin.diagnostics"}} {{t .Locale "admin.settings"}} {{t .Locale "admin.diagnostics"}} {{t .Locale "admin.diagnostics"}} {{t .Locale "admin.overview"}} {{t $.Locale .}} {{t .Locale "admin.access"}} {{t .Locale "admin.diagnostics"}} {{t .Locale "admin.eyebrow"}} {{t .Locale .Flash}} {{t .Locale "admin.eyebrow"}} {{t .Locale .Flash}} {{t .Locale "admin.resultTitle"}} {{t .Locale "admin.oneTimePasswordNotice"}} {{t .Locale "admin.oneTimePasswordHint"}} {{t .Locale "admin.settings"}} {{t .Locale "admin.smtpConfigured"}} {{t .Locale "admin.settings"}} {{t .Locale .Flash}} {{t .Locale "admin.smtpConfigured"}} {{t .Locale "admin.readOnlyConfig"}} {{t .Locale "admin.storage"}} {{t .Locale .Flash}} {{t .Locale "admin.retentionNote"}} {{t .Locale "admin.access"}} {{t .Locale "admin.storage"}} {{t .Locale "auth.account"}} {{t .Locale "confirm.description"}} {{t .Locale "auth.account"}} {{t .Locale "confirm.description"}} {{t .Locale "user.account"}} {{.Email}}{{t .Locale "admin.dashboard"}}
{{t .Locale "admin.serviceHealth"}}
{{t .Locale "admin.users"}}
{{range .AdminUsers}}{{t .Locale "field.username"}} {{t .Locale "field.email"}} {{t .Locale "admin.devices"}} {{t .Locale "device.status"}} {{t .Locale "common.actions"}} {{else}}{{.Username}} {{.Email}} {{.Devices}} {{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}} {{t $.Locale "admin.manage"}}
{{end}}{{t .Locale "admin.noUsers"}} {{t .Locale "admin.devices"}}
{{range .AdminDevices}}{{t .Locale "device.name"}} {{t .Locale "admin.user"}} {{t .Locale "device.vault"}} {{t .Locale "device.lastSeen"}} {{t .Locale "device.status"}} {{t .Locale "common.actions"}} {{else}}{{.Name}} {{.User}} {{short .Vault 16}} {{.LastSeen}} {{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}} {{if not .Revoked}}{{end}} {{end}}{{t .Locale "admin.noDevices"}} {{t .Locale "admin.vaults"}}
{{range .Vaults}}{{t .Locale "admin.user"}} {{t .Locale "device.vault"}} {{t .Locale "admin.devices"}} {{t .Locale "admin.operations"}} {{t .Locale "admin.lastActivity"}} {{else}}{{.User}} {{short .Vault 24}} {{.Devices}} {{.Operations}} {{.LastActivity}} {{end}}{{t .Locale "admin.noVaults"}} {{t .Locale "admin.storage"}}
{{t .Locale "admin.audit"}}
{{range .Audit}}{{t .Locale "admin.event"}} {{t .Locale "admin.user"}} {{t .Locale "device.name"}} {{t .Locale "admin.time"}} {{else}}{{.Event}} {{.User}} {{.Device}} {{.At}} {{end}}{{t .Locale "admin.noAudit"}} {{t .Locale "admin.smtpTitle"}}
- {{else if eq .AdminPage "diagnostics"}}{{t .Locale "admin.diagnostics"}}
{{t .Locale "admin.audit"}}
+
+ {{range .Audit}}{{t .Locale "admin.time"}} {{t .Locale "admin.event"}} {{t .Locale "admin.user"}} {{t .Locale "device.name"}} {{t .Locale "admin.ip"}} {{t .Locale "admin.severity"}} {{t .Locale "admin.message"}} {{else}}{{webtime $.Locale .At}} {{auditlabel $.Locale .Event}} {{.User}} {{.Device}} {{.IP}} {{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}} {{.Message}} {{end}}{{t .Locale "admin.noAudit"}} {{t .Locale "admin.dashboard"}}
+ {{t .Locale "common.warning"}}
{{range .Warnings}}{{t .Locale "admin.serviceHealth"}}
{{t .Locale "admin.audit"}}
{{range .Audit}}{{t .Locale "admin.event"}} {{t .Locale "admin.time"}} {{else}}{{auditlabel $.Locale .Event}} {{webtime $.Locale .At}} {{end}}{{t .Locale "admin.noAudit"}} {{t .Locale "admin.devices"}}
+
+ {{range .AdminDevices}}{{t .Locale "device.name"}} {{t .Locale "admin.user"}} {{t .Locale "device.vault"}} {{t .Locale "device.version"}} {{t .Locale "admin.ip"}} {{t .Locale "admin.created"}} {{t .Locale "device.lastSeen"}} {{t .Locale "device.status"}} {{t .Locale "common.actions"}} {{else}}{{.Name}}{{if .TokenHint}}{{.TokenHint}}{{end}} {{.User}} {{short .Vault 16}} {{.Version}} {{.LastIP}} {{webtime $.Locale .CreatedAt}} {{webtime $.Locale .LastSeen}} {{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}} {{if not .Revoked}}{{else}}{{end}} {{end}}{{t .Locale "admin.noDevices"}} {{t .Locale "admin.diagnostics"}}
+ {{t .Locale "admin.loginTitle"}}
{{if .Flash}}{{t .Locale "admin.loginTitle"}}
{{if .Flash}}{{t .Locale "admin.resetPassword"}}
+ {{.OneTimeSecret}}{{t .Locale "admin.settings"}}
{{t .Locale "admin.general"}}
{{t .Locale "admin.smtpTitle"}}
{{t .Locale "admin.settings"}}
+ {{if .Flash}}{{t .Locale "admin.general"}}
+
+ {{t .Locale "admin.network"}}
{{t .Locale "admin.smtpTitle"}}
+ {{t .Locale "admin.limits"}}
{{t .Locale "admin.storage"}}
+ {{if .Flash}}{{t .Locale "admin.users"}}
{{range .AdminUsers}}{{t .Locale "field.username"}} {{t .Locale "field.email"}} {{t .Locale "admin.devices"}} {{t .Locale "admin.vaults"}} {{t .Locale "admin.lastSeen"}} {{t .Locale "admin.created"}} {{t .Locale "device.status"}} {{t .Locale "common.actions"}} {{else}}{{.Username}} {{.Email}} {{.Devices}} {{.Vaults}} {{webtime $.Locale .LastSeen}} {{webtime $.Locale .CreatedAt}} {{if .Blocked}}{{t $.Locale "admin.blocked"}}{{else if .Confirmed}}{{t $.Locale "device.active"}}{{else}}{{t $.Locale "admin.unconfirmed"}}{{end}} {{t $.Locale "admin.manage"}}
{{if not .Confirmed}}{{end}} {{end}}{{t .Locale "admin.noUsers"}} {{t .Locale "admin.vaults"}}
+ {{range .Vaults}}{{t .Locale "admin.user"}} {{t .Locale "device.vault"}} {{t .Locale "admin.devices"}} {{t .Locale "admin.operations"}} {{t .Locale "admin.lastActivity"}} {{else}}{{.User}} {{short .Vault 24}} {{.Devices}} {{.Operations}} {{.LastActivity}} {{end}}{{t .Locale "admin.noVaults"}} {{t .Locale "confirm.title"}}
{{t .Locale "confirm.title"}}
{{.UserName}}
{{t .Locale .Flash}}
{{end}}| {{t .Locale "device.name"}} | {{t .Locale "device.vault"}} | {{t .Locale "device.version"}} | {{t .Locale "device.lastSeen"}} | {{t .Locale "device.status"}} | {{t .Locale "common.actions"}} |
|---|---|---|---|---|---|
| {{.Name}} | {{short .Vault 18}} | {{.ClientVersion}} | {{.LastSeen}} | {{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}} | {{if not .Revoked}}{{end}} |
{{t .Locale "user.noDevices"}}
{{end}}{{t .Locale "user.account"}}
{{.Email}} · {{if .UserConfirmed}}{{t .Locale "user.emailConfirmed"}}{{else}}{{t .Locale "user.emailUnconfirmed"}}{{end}}
{{t .Locale .Flash}}
{{end}} +{{t .Locale "user.connectInstruction"}}
| {{t .Locale "device.name"}} | {{t .Locale "device.vault"}} | {{t .Locale "device.version"}} | {{t .Locale "device.created"}} | {{t .Locale "device.lastSeen"}} | {{t .Locale "device.status"}} | {{t .Locale "common.actions"}} |
|---|---|---|---|---|---|---|
| {{.Name}} | {{short .Vault 18}} | {{.ClientVersion}} | {{webtime $.Locale .CreatedAt}} | {{webtime $.Locale .LastSeen}} | {{if .Revoked}}{{t $.Locale "device.revoked"}}{{else}}{{t $.Locale "device.active"}}{{end}} | {{if not .Revoked}}{{end}} |
{{t .Locale "user.noDevices"}}
{{end}}{{t .Locale "auth.account"}}
{{t .Locale "auth.forgotDescription"}}
-{{t .Locale "auth.backLogin"}}
{{end}} diff --git a/internal/server/web/templates/home.html b/internal/server/web/templates/home.html index 4f26f6c..a80a881 100644 --- a/internal/server/web/templates/home.html +++ b/internal/server/web/templates/home.html @@ -5,6 +5,6 @@{{t .Locale "home.description"}}
{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · {{.Status}}
+{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · {{statuslabel .Locale .Status}}
{{end}} diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html index 0a54bcc..cad2e9b 100644 --- a/internal/server/web/templates/layout.html +++ b/internal/server/web/templates/layout.html @@ -17,7 +17,7 @@