feat(web): complete localized sync server console
This commit is contained in:
parent
0014910638
commit
e8605f15a2
51
README.md
51
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}}&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}}&status={{.List.Status}}&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}}&status={{.List.Status}}&page={{.List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
{{define "admin_login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -1,2 +1,47 @@
|
|||
{{define "admin_settings"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.settings"}}</h1><section class="card panel"><h2>{{t .Locale "admin.general"}}</h2><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings"><label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label><label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label><label><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label><label>{{t .Locale "field.password"}}<input name="password" type="password" required></label><button class="button primary">{{t .Locale "admin.saveUser"}}</button></form></section><section class="card panel"><h2>{{t .Locale "admin.smtpTitle"}}</h2><p class="muted">{{t .Locale "admin.smtpConfigured"}}</p><a class="button secondary" href="/admin/dashboard">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">
|
||||
{{template "admin_nav" .}}
|
||||
<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.settings"}}</p>
|
||||
<h1>{{t .Locale "admin.settings"}}</h1>
|
||||
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
|
||||
<section class="card panel">
|
||||
<h2>{{t .Locale "admin.general"}}</h2>
|
||||
<form method="post" action="/admin/action" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings">
|
||||
<label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label>
|
||||
<label>{{t .Locale "admin.publicURL"}}<input name="public_url" type="url" value="{{.PublicURL}}" placeholder="https://sync.example.test"></label>
|
||||
<label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label>
|
||||
<label class="check-field"><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button primary">{{t .Locale "admin.saveSettings"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card panel"><h2>{{t .Locale "admin.network"}}</h2><dl class="details"><dt>{{t .Locale "admin.trustedProxies"}}</dt><dd>{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}</dd></dl></section>
|
||||
<section class="card panel">
|
||||
<h2>{{t .Locale "admin.smtpTitle"}}</h2>
|
||||
<p class="muted">{{t .Locale "admin.smtpConfigured"}}</p>
|
||||
<form method="post" action="/admin/action" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp">
|
||||
<label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}" autocomplete="off"></label>
|
||||
<label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" inputmode="numeric" value="{{.SMTP.Port}}"></label>
|
||||
<label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}" autocomplete="username"></label>
|
||||
<label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label>
|
||||
<label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>{{t .Locale "admin.smtpStartTLS"}}</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>{{t .Locale "admin.smtpTLS"}}</option></select></label>
|
||||
<label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" type="email" value="{{.SMTP.From}}"></label>
|
||||
<label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" type="url" value="{{.SMTP.ServerURL}}"></label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/action" class="inline-form top-gap">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp-test">
|
||||
<label>{{t .Locale "field.email"}}<input name="test_to" type="email" placeholder="{{.SMTP.From}}"></label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button secondary" type="submit">{{t .Locale "admin.smtpTest"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card panel"><h2>{{t .Locale "admin.limits"}}</h2><p class="muted">{{t .Locale "admin.readOnlyConfig"}}</p><dl class="details"><dt>{{t .Locale "admin.maxJSONBody"}}</dt><dd>{{webbytes .Limits.MaxJSONBody}}</dd><dt>{{t .Locale "admin.maxPushOperations"}}</dt><dd>{{.Limits.MaxPushOperations}}</dd><dt>{{t .Locale "admin.maxPullPage"}}</dt><dd>{{.Limits.MaxPullPage}}</dd><dt>{{t .Locale "admin.maxBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxBlobBytes}}</dd><dt>{{t .Locale "admin.maxVaultBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxVaultBlobBytes}}</dd><dt>{{t .Locale "admin.maxUserBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxUserBlobBytes}}</dd></dl></section>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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}}&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}}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
{{define "confirm"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,7 @@
|
|||
{{define "dashboard"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<section class="card table-card"><h2>{{t .Locale "user.devices"}}</h2>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}} · {{if .UserConfirmed}}<span class="badge ok">{{t .Locale "user.emailConfirmed"}}</span>{{else}}<span class="badge danger">{{t .Locale "user.emailUnconfirmed"}}</span>{{end}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>
|
||||
{{if .Flash}}<p class="flash {{if .FlashError}}error{{else}}success{{end}}" role="{{if .FlashError}}alert{{else}}status{{end}}">{{t .Locale .Flash}}</p>{{end}}
|
||||
<section class="card panel"><p class="muted">{{t .Locale "user.connectInstruction"}}</p></section>
|
||||
<section class="card table-card"><div class="section-heading"><h2>{{t .Locale "user.devices"}}</h2><form class="list-filter" method="get" action="/dashboard"><label class="sr-only">{{t .Locale "user.filterDevices"}}</label><input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "user.filterDevices"}}"><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form></div>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{{define "forgot"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.forgotTitle"}}</h1><p class="muted">{{t .Locale "auth.forgotDescription"}}</p>
|
||||
<form method="post" action="/forgot" class="stack"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
|
||||
<form method="post" action="/forgot" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@
|
|||
<h1>{{t .Locale "home.heading"}}</h1>
|
||||
<p class="lead">{{t .Locale "home.description"}}</p>
|
||||
<div class="actions"><a class="button primary" href="/login">{{t .Locale "home.login"}}</a>{{if .AllowRegistration}}<a class="button secondary" href="/register">{{t .Locale "home.register"}}</a>{{end}}</div>
|
||||
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{.Status}}</span></p>
|
||||
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{statuslabel .Locale .Status}}</span></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
</nav>
|
||||
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
|
||||
<input type="hidden" name="from" value="{{.CurrentURL}}">
|
||||
{{if .CSRF}}<input type="hidden" name="csrf_token" value="{{.CSRF}}">{{end}}
|
||||
<input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}">
|
||||
<label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
|
||||
<select id="locale" name="locale" data-auto-submit>
|
||||
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
</header>
|
||||
<main class="page-shell">{{template "content" .}}</main>
|
||||
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
|
||||
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-title"><form method="dialog" class="stack"><h2 id="confirm-dialog-title">{{t .Locale "admin.confirmTitle"}}</h2><p id="confirm-dialog-message"></p><div class="actions"><button class="button secondary" value="cancel">{{t .Locale "admin.modalCancel"}}</button><button class="button danger" value="confirm">{{t .Locale "admin.modalConfirm"}}</button></div></form></dialog>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/login" class="stack"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
|
||||
<form method="post" action="/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
|
||||
<p class="muted"><a href="/forgot">{{t .Locale "auth.forgot"}}</a>{{if .AllowRegistration}} · <a href="/register">{{t .Locale "auth.register"}}</a>{{end}}</p></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/register" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
|
||||
<form method="post" action="/register" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
|
||||
<p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/reset" class="stack"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
|
||||
<form method="post" action="/reset" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -1,2 +1,10 @@
|
|||
{{define "vault_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p><div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article><article class="card stat"><strong>{{.VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p><a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p>
|
||||
<div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}</span></article><article class="card stat"><strong>{{webbytes .VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div>
|
||||
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p></section>
|
||||
<section class="card table-card"><h2>{{t .Locale "admin.devices"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th></tr></thead><tbody>{{range .VaultDevices}}<tr><td>{{.Name}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
<a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a>
|
||||
</div></section>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/hex"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -13,6 +14,38 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type oneTimeWebSecret struct {
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// storeAdminOneTimeSecret keeps a generated password only long enough for the
|
||||
// currently authenticated administrator to retrieve it once. It is never
|
||||
// written to the database, URL, audit log, or cookie.
|
||||
func (s *Server) storeAdminOneTimeSecret(sessionToken, secret string) {
|
||||
s.secretMu.Lock()
|
||||
defer s.secretMu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
for key, value := range s.webSecrets {
|
||||
if !now.Before(value.ExpiresAt) {
|
||||
delete(s.webSecrets, key)
|
||||
}
|
||||
}
|
||||
s.webSecrets[sha256Hex(sessionToken)] = oneTimeWebSecret{Value: secret, ExpiresAt: now.Add(5 * time.Minute)}
|
||||
}
|
||||
|
||||
func (s *Server) takeAdminOneTimeSecret(sessionToken string) string {
|
||||
s.secretMu.Lock()
|
||||
defer s.secretMu.Unlock()
|
||||
key := sha256Hex(sessionToken)
|
||||
value, ok := s.webSecrets[key]
|
||||
delete(s.webSecrets, key)
|
||||
if !ok || !time.Now().UTC().Before(value.ExpiresAt) {
|
||||
return ""
|
||||
}
|
||||
return value.Value
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, http.MethodGet)
|
||||
|
|
@ -21,6 +54,28 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
|||
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPasswordResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !s.requireAdminCookie(w, r) {
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie("admin_session")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
secret := s.takeAdminOneTimeSecret(cookie.Value)
|
||||
if secret == "" {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||||
s.renderPage(w, r, "admin_password_result", webPage{Title: "admin.resetPassword", Admin: true, AdminPage: "users", OneTimeSecret: secret})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, http.MethodGet)
|
||||
|
|
@ -35,7 +90,7 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
var d webVaultDetail
|
||||
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT o.op_id), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Operations, &d.LastActivity); err != nil {
|
||||
if err := s.db.QueryRow(`SELECT COALESCE((SELECT username FROM server_users WHERE id=?),''), COUNT(DISTINCT d.id), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')='' THEN d.id END), COUNT(DISTINCT CASE WHEN COALESCE(d.revoked_at,'')!='' THEN d.id END), COUNT(DISTINCT o.op_id), COALESCE(MAX(o.server_sequence),0), COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE d.user_id=? AND d.vault_id=?`, userID, userID, vaultID).Scan(&d.User, &d.Devices, &d.Active, &d.Revoked, &d.Operations, &d.Sequence, &d.LastActivity); err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
|
@ -44,7 +99,31 @@ func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request)
|
|||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d})
|
||||
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id WHERE d.user_id=? AND d.vault_id=? ORDER BY d.created_at DESC`, userID, vaultID)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var devices []webAdminDevice
|
||||
for rows.Next() {
|
||||
var device webAdminDevice
|
||||
var revoked, prefix, suffix string
|
||||
if err := rows.Scan(&device.ID, &device.Name, &device.User, &device.Vault, &device.Version, &device.LastIP, &device.LastSeen, &revoked, &device.CreatedAt, &prefix, &suffix); err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
device.Revoked = revoked != ""
|
||||
if prefix != "" || suffix != "" {
|
||||
device.TokenHint = prefix + "…" + suffix
|
||||
}
|
||||
devices = append(devices, device)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, AdminPage: "vaults", VaultDetail: d, VaultDevices: devices})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -122,6 +201,20 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
|
||||
switch page {
|
||||
case "dashboard":
|
||||
data.Audit, _, err = s.webAudit(webList{Page: 1, PerPage: 5})
|
||||
if err == nil {
|
||||
data.AdminDevices, _, err = s.webAdminDevices(webList{Page: 1, PerPage: 5})
|
||||
}
|
||||
if !data.Health.DatabaseReachable || !data.Health.BlobStorageWritable {
|
||||
data.Warnings = append(data.Warnings, "admin.warningReadiness")
|
||||
}
|
||||
if s.smtpGet("smtp_host") == "" {
|
||||
data.Warnings = append(data.Warnings, "admin.warningSMTP")
|
||||
}
|
||||
if stats.Operations > 100000 {
|
||||
data.Warnings = append(data.Warnings, "admin.warningOperations")
|
||||
}
|
||||
case "users":
|
||||
data.List = webListFromRequest(r)
|
||||
data.AdminUsers, data.List, err = s.webAdminUsers(data.List)
|
||||
|
|
@ -135,21 +228,38 @@ func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
|
|||
data.Audit, data.List, err = s.webAudit(data.List)
|
||||
case "settings":
|
||||
data.SMTP = s.webSMTP()
|
||||
switch r.URL.Query().Get("flash") {
|
||||
case "settings_saved":
|
||||
data.Flash = "admin.settingsSaved"
|
||||
case "smtp_saved":
|
||||
data.Flash = "admin.smtpSaved"
|
||||
case "smtp_test_passed":
|
||||
data.Flash = "admin.smtpPassed"
|
||||
case "smtp_test_failed":
|
||||
data.Flash = "admin.smtpTestFailed"
|
||||
}
|
||||
case "storage":
|
||||
if r.URL.Query().Get("flash") == "cleanup_done" {
|
||||
data.Flash = "admin.cleanupDone"
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("admin %s: %v", page, err)
|
||||
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
|
||||
return
|
||||
}
|
||||
if page == "settings" {
|
||||
s.renderPage(w, r, "admin_settings", data)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "admin", data)
|
||||
s.renderPage(w, r, "admin_"+page, data)
|
||||
}
|
||||
|
||||
func webListFromRequest(r *http.Request) webList {
|
||||
list := webList{Query: strings.TrimSpace(r.URL.Query().Get("q")), Status: strings.TrimSpace(r.URL.Query().Get("status")), Page: 1, PerPage: 25}
|
||||
trim := func(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 160 {
|
||||
return value[:160]
|
||||
}
|
||||
return value
|
||||
}
|
||||
list := webList{Query: trim(r.URL.Query().Get("q")), Status: trim(r.URL.Query().Get("status")), Sort: trim(r.URL.Query().Get("sort")), User: trim(r.URL.Query().Get("user")), Vault: trim(r.URL.Query().Get("vault")), Version: trim(r.URL.Query().Get("version")), Event: trim(r.URL.Query().Get("event")), Severity: trim(r.URL.Query().Get("severity")), Page: 1, PerPage: 25}
|
||||
if value, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && value > 0 {
|
||||
list.Page = value
|
||||
}
|
||||
|
|
@ -198,9 +308,14 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
|
|||
return nil, list, err
|
||||
}
|
||||
list = finishWebList(list, total)
|
||||
order := map[string]string{"username": "u.username COLLATE NOCASE ASC", "last_seen": "COALESCE(u.last_seen,'') DESC", "created": "u.created_at DESC"}[list.Sort]
|
||||
if order == "" {
|
||||
list.Sort = "created"
|
||||
order = "u.created_at DESC"
|
||||
}
|
||||
queryArgs := append([]interface{}{}, args...)
|
||||
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
|
||||
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id),(SELECT COUNT(DISTINCT vd.vault_id) FROM server_devices vd WHERE vd.user_id=u.id AND COALESCE(vd.vault_id,'')!='') FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id`+where+` GROUP BY u.id ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
|
|
@ -209,7 +324,7 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
|
|||
for rows.Next() {
|
||||
var u webAdminUser
|
||||
var confirmed, blocked int
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices, &u.Vaults); err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
u.Confirmed = confirmed != 0
|
||||
|
|
@ -222,27 +337,44 @@ func (s *Server) webAdminUsers(list webList) ([]webAdminUser, webList, error) {
|
|||
func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error) {
|
||||
where := ""
|
||||
args := []interface{}{}
|
||||
if list.Query != "" {
|
||||
where = " WHERE (d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)"
|
||||
like := "%" + list.Query + "%"
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
if list.Status == "active" || list.Status == "revoked" {
|
||||
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
|
||||
addCondition := func(condition string, values ...interface{}) {
|
||||
if where == "" {
|
||||
where = " WHERE " + condition
|
||||
} else {
|
||||
where += " AND " + condition
|
||||
}
|
||||
args = append(args, values...)
|
||||
}
|
||||
if list.Query != "" {
|
||||
like := "%" + list.Query + "%"
|
||||
addCondition("(d.name LIKE ? OR u.username LIKE ? OR d.vault_id LIKE ?)", like, like, like)
|
||||
}
|
||||
if list.Status == "active" || list.Status == "revoked" {
|
||||
condition := map[string]string{"active": "COALESCE(d.revoked_at,'')=''", "revoked": "COALESCE(d.revoked_at,'')!=''"}[list.Status]
|
||||
addCondition(condition)
|
||||
}
|
||||
if list.User != "" {
|
||||
addCondition("u.username LIKE ?", "%"+list.User+"%")
|
||||
}
|
||||
if list.Vault != "" {
|
||||
addCondition("d.vault_id LIKE ?", "%"+list.Vault+"%")
|
||||
}
|
||||
if list.Version != "" {
|
||||
addCondition("d.client_version LIKE ?", "%"+list.Version+"%")
|
||||
}
|
||||
var total int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where, args...).Scan(&total); err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
list = finishWebList(list, total)
|
||||
order := map[string]string{"name": "d.name COLLATE NOCASE ASC", "last_seen": "COALESCE(d.last_seen,'') DESC", "created": "d.created_at DESC"}[list.Sort]
|
||||
if order == "" {
|
||||
list.Sort = "created"
|
||||
order = "d.created_at DESC"
|
||||
}
|
||||
queryArgs := append([]interface{}{}, args...)
|
||||
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
|
||||
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_ip,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at,COALESCE(d.token_prefix,''),COALESCE(d.token_suffix,'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id`+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
|
|
@ -250,10 +382,13 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error
|
|||
var out []webAdminDevice
|
||||
for rows.Next() {
|
||||
var d webAdminDevice
|
||||
var revoked string
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil {
|
||||
var revoked, prefix, suffix string
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastIP, &d.LastSeen, &revoked, &d.CreatedAt, &prefix, &suffix); err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
if prefix != "" || suffix != "" {
|
||||
d.TokenHint = prefix + "…" + suffix
|
||||
}
|
||||
d.Revoked = revoked != ""
|
||||
if d.LastSeen == "" {
|
||||
d.LastSeen = "—"
|
||||
|
|
@ -283,19 +418,37 @@ func (s *Server) webVaults() ([]webVault, error) {
|
|||
func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
|
||||
where := ""
|
||||
args := []interface{}{}
|
||||
addCondition := func(condition string, values ...interface{}) {
|
||||
if where == "" {
|
||||
where = " WHERE " + condition
|
||||
} else {
|
||||
where += " AND " + condition
|
||||
}
|
||||
args = append(args, values...)
|
||||
}
|
||||
if list.Query != "" {
|
||||
where = " WHERE (event_type LIKE ? OR user_id LIKE ? OR device_id LIKE ?)"
|
||||
like := "%" + list.Query + "%"
|
||||
args = append(args, like, like, like)
|
||||
addCondition("(a.event_type LIKE ? OR a.user_id LIKE ? OR a.device_id LIKE ?)", like, like, like)
|
||||
}
|
||||
if list.Event != "" {
|
||||
addCondition("a.event_type LIKE ?", "%"+list.Event+"%")
|
||||
}
|
||||
if list.User != "" {
|
||||
addCondition("a.user_id LIKE ?", "%"+list.User+"%")
|
||||
}
|
||||
if list.Severity == "error" {
|
||||
addCondition("(a.event_type LIKE '%failed%' OR a.event_type LIKE '%error%')")
|
||||
} else if list.Severity == "warning" {
|
||||
addCondition("a.event_type LIKE '%rate_limit%'")
|
||||
}
|
||||
var total int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log"+where, args...).Scan(&total); err != nil {
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_audit_log a"+where, args...).Scan(&total); err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
list = finishWebList(list, total)
|
||||
queryArgs := append([]interface{}{}, args...)
|
||||
queryArgs = append(queryArgs, list.PerPage, (list.Page-1)*list.PerPage)
|
||||
rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log`+where+` ORDER BY id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
rows, err := s.db.Query(`SELECT a.event_type,COALESCE(u.username,a.user_id,''),COALESCE(d.name,a.device_id,''),COALESCE(a.ip,''),COALESCE(a.message,''),a.created_at FROM server_audit_log a LEFT JOIN server_users u ON u.id=a.user_id LEFT JOIN server_devices d ON d.id=a.device_id`+where+` ORDER BY a.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
|
|
@ -303,14 +456,25 @@ func (s *Server) webAudit(list webList) ([]webAudit, webList, error) {
|
|||
var out []webAudit
|
||||
for rows.Next() {
|
||||
var a webAudit
|
||||
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil {
|
||||
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.IP, &a.Message, &a.At); err != nil {
|
||||
return nil, list, err
|
||||
}
|
||||
a.Severity = auditSeverity(a.Event)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, list, rows.Err()
|
||||
}
|
||||
|
||||
func auditSeverity(event string) string {
|
||||
if strings.Contains(event, "failed") || strings.Contains(event, "error") {
|
||||
return "error"
|
||||
}
|
||||
if strings.Contains(event, "rate_limit") || strings.Contains(event, "revoked") || strings.Contains(event, "blocked") {
|
||||
return "warning"
|
||||
}
|
||||
return "info"
|
||||
}
|
||||
|
||||
func (s *Server) webSMTP() webSMTP {
|
||||
return webSMTP{Host: s.smtpGet("smtp_host"), Port: s.smtpGet("smtp_port"), User: s.smtpGet("smtp_user"), Security: s.smtpGet("smtp_security"), From: s.smtpGet("smtp_from"), ServerURL: s.smtpGet("server_url")}
|
||||
}
|
||||
|
|
@ -343,10 +507,19 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
if locale != "ru" && locale != "en" {
|
||||
locale = "en"
|
||||
}
|
||||
publicURL := strings.TrimRight(strings.TrimSpace(r.FormValue("public_url")), "/")
|
||||
if publicURL != "" {
|
||||
parsed, err := url.ParseRequestURI(publicURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
s.renderWebError(w, r, http.StatusBadRequest, "error.invalidPublicURL", "/admin/settings")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.cfg.mu.Lock()
|
||||
s.cfg.Web.DefaultLocale = locale
|
||||
s.cfg.Web.AllowRegistration = r.FormValue("allow_registration") == "on"
|
||||
s.cfg.Web.ServerName = strings.TrimSpace(r.FormValue("server_name"))
|
||||
s.cfg.PublicURL = publicURL
|
||||
err := s.cfg.saveLocked()
|
||||
s.cfg.mu.Unlock()
|
||||
if err != nil {
|
||||
|
|
@ -354,7 +527,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
s.auditLog("web_settings_updated", "", "", s.clientIP(r), "updated by administrator")
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/settings?flash=settings_saved", http.StatusSeeOther)
|
||||
case "toggle-user":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
||||
|
|
@ -412,14 +585,36 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
case "confirm-user":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
||||
return
|
||||
}
|
||||
id := r.FormValue("id")
|
||||
result, err := s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", id)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if changed, err := result.RowsAffected(); err != nil || changed == 0 {
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
} else {
|
||||
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
|
||||
}
|
||||
return
|
||||
}
|
||||
s.auditLog("user_confirmed", id, "", s.clientIP(r), "confirmed by administrator")
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
case "reset-user-password":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
||||
return
|
||||
}
|
||||
id, password := r.FormValue("id"), r.FormValue("new_password")
|
||||
if err := validatePassword(password); err != "" {
|
||||
s.renderWebError(w, r, http.StatusBadRequest, "error.passwordInvalid", "/admin/users")
|
||||
id := r.FormValue("id")
|
||||
password, err := randomSecret(16)
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
|
|
@ -446,7 +641,13 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
cookie, err := r.Cookie("admin_session")
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.storeAdminOneTimeSecret(cookie.Value, password)
|
||||
http.Redirect(w, r, "/admin/password-result", http.StatusSeeOther)
|
||||
case "delete-user":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
||||
|
|
@ -483,6 +684,43 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
|
||||
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
|
||||
case "delete-device":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
|
||||
return
|
||||
}
|
||||
id := r.FormValue("id")
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var revoked string
|
||||
if err := tx.QueryRow("SELECT COALESCE(revoked_at,'') FROM server_devices WHERE id=?", id).Scan(&revoked); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/devices")
|
||||
} else {
|
||||
jsonInternalError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if revoked == "" {
|
||||
s.renderWebError(w, r, http.StatusConflict, "error.deviceMustBeRevoked", "/admin/devices")
|
||||
return
|
||||
}
|
||||
for _, statement := range []string{"DELETE FROM server_user_devices WHERE device_id=?", "DELETE FROM server_devices WHERE id=?"} {
|
||||
if _, err := tx.Exec(statement, id); err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
jsonInternalError(w, err)
|
||||
return
|
||||
}
|
||||
s.auditLog("device_deleted", "", id, s.clientIP(r), "revoked device deleted by administrator")
|
||||
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
|
||||
case "smtp":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
|
||||
|
|
@ -511,7 +749,29 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/settings?flash=smtp_saved", http.StatusSeeOther)
|
||||
case "smtp-test":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
|
||||
return
|
||||
}
|
||||
smtp := s.webSMTP()
|
||||
to := strings.TrimSpace(r.FormValue("test_to"))
|
||||
if to == "" {
|
||||
to = smtp.From
|
||||
}
|
||||
if smtp.Host == "" || smtp.Port == "" || smtp.From == "" || to == "" {
|
||||
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.smtpTest(smtp.Host, smtp.Port, smtp.User, s.smtpGet("smtp_pass"), smtp.Security, smtp.From, to); err != nil {
|
||||
log.Printf("admin SMTP test failed: %v", err)
|
||||
s.auditLog("smtp_test_failed", "", "", s.clientIP(r), "tested by administrator")
|
||||
http.Redirect(w, r, "/admin/settings?flash=smtp_test_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.auditLog("smtp_test_passed", "", "", s.clientIP(r), "tested by administrator")
|
||||
http.Redirect(w, r, "/admin/settings?flash=smtp_test_passed", http.StatusSeeOther)
|
||||
case "cleanup":
|
||||
if !s.adminReauth(r, session.SubjectID) {
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/storage")
|
||||
|
|
@ -522,7 +782,7 @@ func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
s.auditLog("retention_cleanup", "", "", s.clientIP(r), "safe retention cleanup run by administrator")
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage?flash=cleanup_done", http.StatusSeeOther)
|
||||
default:
|
||||
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const webLocaleCookieName = "verstak_locale"
|
||||
const (
|
||||
webLocaleCookieName = "verstak_locale"
|
||||
webLocaleCSRFCookieName = "verstak_locale_csrf"
|
||||
)
|
||||
|
||||
func isSupportedWebLocale(locale string) bool {
|
||||
return locale == "en" || locale == "ru"
|
||||
|
|
@ -69,3 +73,38 @@ func (s *Server) setWebLocale(w http.ResponseWriter, r *http.Request, locale str
|
|||
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: 365 * 24 * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
// webLocaleCSRF protects the public language-preference form without
|
||||
// conflating it with session CSRF tokens. Its value is rendered by the server,
|
||||
// while the matching cookie is HttpOnly and never read by client-side code.
|
||||
func (s *Server) webLocaleCSRF(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(webLocaleCSRFCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
token, err := randomSecret(32)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webLocaleCSRFCookieName, Value: token, Path: "/", HttpOnly: true,
|
||||
Secure: s.requestIsHTTPS(r), SameSite: http.SameSiteStrictMode, MaxAge: 24 * 60 * 60,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
func (s *Server) verifyWebLocaleCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(webLocaleCSRFCookieName)
|
||||
if err != nil || cookie.Value == "" || !s.sameOrigin(r) {
|
||||
return false
|
||||
}
|
||||
candidate := r.FormValue("locale_csrf")
|
||||
return candidate != "" && subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(candidate)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) requirePublicWebMutation(w http.ResponseWriter, r *http.Request, back string) bool {
|
||||
if s.verifyWebLocaleCSRF(r) {
|
||||
return true
|
||||
}
|
||||
s.renderWebError(w, r, http.StatusForbidden, "error.tryAgain", back)
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ func TestResolveWebLocaleCookieOverridesSystemAcceptLanguage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFormatWebTimeUsesSelectedLocale(t *testing.T) {
|
||||
stamp := "2026-07-17T13:45:00Z"
|
||||
if got := formatWebTime("ru", stamp); !strings.Contains(got, "17.07.2026") {
|
||||
t.Fatalf("Russian timestamp = %q", got)
|
||||
}
|
||||
if got := formatWebTime("en", stamp); !strings.Contains(got, "Jul 17, 2026") {
|
||||
t.Fatalf("English timestamp = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationCatalogsHaveMatchingKeysAndHideUnknownKeys(test *testing.T) {
|
||||
for key := range _translations["en"] {
|
||||
if _, ok := _translations["ru"][key]; !ok {
|
||||
|
|
@ -56,6 +66,20 @@ func TestEmbeddedTemplatesUseExternalAssetsAndNoInlineEventHandlers(t *testing.T
|
|||
}
|
||||
}
|
||||
|
||||
func TestConfirmationUsesLocalDialogInsteadOfBrowserPrompt(t *testing.T) {
|
||||
layout, err := webFS.ReadFile("web/templates/layout.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script, err := webFS.ReadFile("web/static/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(layout), `id="confirm-dialog"`) || !strings.Contains(string(script), ".showModal()") || strings.Contains(string(script), "window.confirm") {
|
||||
t.Fatalf("local confirmation dialog is missing or browser prompt remains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedTemplateTranslationKeysExist(t *testing.T) {
|
||||
entries, err := fs.Glob(webFS, "web/templates/*.html")
|
||||
if err != nil {
|
||||
|
|
@ -102,6 +126,22 @@ func TestPublicHomeUsesSharedLocalizedTemplateLayout(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPublicHomeUsesUnavailablePageWhenReadinessFails(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetupRoutes()
|
||||
if err := s.db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if res.Code != http.StatusServiceUnavailable || !strings.Contains(res.Body.String(), `<html lang="en">`) {
|
||||
t.Fatalf("unavailable page=%d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
|
|
@ -125,6 +165,39 @@ func TestPublicTemplateRoutesRenderInBothLocales(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminPagesRenderWithActiveNavigationInBothLocales(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',1,'2026-01-01T00:00:00Z')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES ('d1','Laptop','legacy','u1','vault-a','2026-01-01T00:00:00Z')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := s.createSession(sessionScopeAdmin, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, locale := range []string{"ru", "en"} {
|
||||
for _, tc := range []struct{ path, active string }{
|
||||
{"/admin/dashboard", "/admin/dashboard"}, {"/admin/users", "/admin/users"}, {"/admin/devices", "/admin/devices"}, {"/admin/vaults", "/admin/vaults"}, {"/admin/vault/?user=u1&vault=vault-a", "/admin/vaults"}, {"/admin/storage", "/admin/storage"}, {"/admin/audit", "/admin/audit"}, {"/admin/settings", "/admin/settings"}, {"/admin/diagnostics", "/admin/diagnostics"},
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
||||
req.AddCookie(&http.Cookie{Name: webLocaleCookieName, Value: locale})
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `<html lang="`+locale+`">`) || !strings.Contains(res.Body.String(), `href="`+tc.active+`" aria-current="page"`) {
|
||||
t.Fatalf("%s %s = %d; active navigation missing: %s", locale, tc.path, res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmationPageUsesSharedTemplateAndEscapesToken(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
|
|
@ -223,8 +296,9 @@ func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
|
|||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login"))
|
||||
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/login&locale_csrf=locale-test-token"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/login" {
|
||||
|
|
@ -243,8 +317,9 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
|
|||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token"))
|
||||
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader("locale=ru&from=/reset%3Ftoken%3Dopaque-token&locale_csrf=locale-test-token"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: "locale-test-token"})
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/reset?token=opaque-token" {
|
||||
|
|
@ -252,6 +327,52 @@ func TestLocaleSelectionPreservesResetQuery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLocaleSelectionRejectsMissingOrMismatchedCSRF(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
for _, tc := range []struct {
|
||||
name, body, cookie string
|
||||
}{
|
||||
{"missing", "locale=ru&from=/login", ""},
|
||||
{"mismatched", "locale=ru&from=/login&locale_csrf=other", "locale-test-token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/locale", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if tc.cookie != "" {
|
||||
req.AddCookie(&http.Cookie{Name: webLocaleCSRFCookieName, Value: tc.cookie})
|
||||
}
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden {
|
||||
t.Fatalf("locale CSRF status=%d, want 403", res.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicWebFormsRejectMissingCSRF(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
for _, path := range []string{"/register", "/login", "/forgot", "/reset", "/admin/login"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("username=alice&email=alice%40example.test&password=password&confirm=password"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s without public CSRF = %d, want 403", path, res.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebResponsesHaveSecurityHeaders(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
|
|
@ -349,6 +470,198 @@ func TestAdminUserListSearchStatusAndPagination(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsRendersAndSavesSMTPConfiguration(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetupRoutes()
|
||||
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
get := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
get.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
||||
get.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
getResult := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(getResult, get)
|
||||
if getResult.Code != http.StatusOK {
|
||||
t.Fatalf("settings page = %d: %s", getResult.Code, getResult.Body.String())
|
||||
}
|
||||
for _, field := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} {
|
||||
if !strings.Contains(getResult.Body.String(), `name="`+field+`"`) {
|
||||
t.Fatalf("settings page is missing SMTP field %q: %s", field, getResult.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
body := "csrf_token=" + csrf + "&action=smtp&smtp_host=mail.example.test&smtp_port=587&smtp_user=mailer&smtp_pass=mail-secret&smtp_security=starttls&smtp_from=sync%40example.test&server_url=https%3A%2F%2Fsync.example.test&password=correct+horse+battery+staple"
|
||||
post := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
|
||||
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
post.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
||||
post.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
postResult := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(postResult, post)
|
||||
if postResult.Code != http.StatusSeeOther || postResult.Header().Get("Location") != "/admin/settings?flash=smtp_saved" {
|
||||
t.Fatalf("save SMTP = %d %q: %s", postResult.Code, postResult.Header().Get("Location"), postResult.Body.String())
|
||||
}
|
||||
if got := s.smtpGet("smtp_host"); got != "mail.example.test" {
|
||||
t.Fatalf("saved SMTP host = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDashboardOnlyRendersOwnFilteredDevices(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
s.SetupRoutes()
|
||||
for _, user := range []struct{ id, name string }{{"user-a", "alice"}, {"user-b", "bob"}} {
|
||||
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, device := range []struct{ id, user, name string }{{"device-a", "user-a", "Alice laptop"}, {"device-b", "user-b", "Bob workstation"}} {
|
||||
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,created_at) VALUES (?, ?, ?, ?, 'vault', '2026-01-01T00:00:00Z')", device.id, device.name, "key-"+device.id, device.user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.db.Exec("INSERT INTO server_user_devices (user_id,device_id) VALUES (?,?)", device.user, device.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
token, _, err := s.createSession(sessionScopeUser, "user-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard?q=Alice", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "user_session", Value: token})
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard=%d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "Alice laptop") || strings.Contains(res.Body.String(), "Bob workstation") {
|
||||
t.Fatalf("dashboard leaked or missed device: %s", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDeviceFiltersAndAuditSearchRemainBounded(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
for _, user := range []struct{ id, name string }{{"u1", "alice"}, {"u2", "bob"}} {
|
||||
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, 'hash', 1, '2026-01-01T00:00:00Z')", user.id, user.name, user.name+"@example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, device := range []struct{ id, user, vault, version string }{{"d1", "u1", "vault-a", "2.0"}, {"d2", "u2", "vault-b", "1.0"}} {
|
||||
if _, err := s.db.Exec("INSERT INTO server_devices (id,name,api_key,user_id,vault_id,client_version,created_at) VALUES (?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z')", device.id, device.id, "key-"+device.id, device.user, device.vault, device.version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
devices, list, err := s.webAdminDevices(webList{User: "alice", Vault: "vault-a", Version: "2.0", Sort: "name", Page: 1, PerPage: 25})
|
||||
if err != nil || len(devices) != 1 || devices[0].ID != "d1" || list.Sort != "name" {
|
||||
t.Fatalf("filtered devices=%+v list=%+v err=%v", devices, list, err)
|
||||
}
|
||||
if _, err := s.db.Exec("INSERT INTO server_audit_log (event_type,user_id,message,created_at) VALUES ('device_paired','u1','safe','2026-01-01T00:00:00Z')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit, _, err := s.webAudit(webList{Event: "' OR 1=1 --", Page: 1, PerPage: 25})
|
||||
if err != nil || len(audit) != 0 {
|
||||
t.Fatalf("audit injection filter returned=%+v err=%v", audit, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanConfirmUnconfirmedUserWithCSRFAndReauth(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetupRoutes()
|
||||
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','hash',0,'2026-01-01T00:00:00Z')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, csrf, err := s.createSession(sessionScopeAdmin, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "csrf_token=" + csrf + "&action=confirm-user&id=u1&password=correct+horse+battery+staple"
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
|
||||
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
res := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusSeeOther || res.Header().Get("Location") != "/admin/users" {
|
||||
t.Fatalf("confirm user=%d %q: %s", res.Code, res.Header().Get("Location"), res.Body.String())
|
||||
}
|
||||
var confirmed int
|
||||
if err := s.db.QueryRow("SELECT confirmed FROM server_users WHERE id='u1'").Scan(&confirmed); err != nil || confirmed != 1 {
|
||||
t.Fatalf("confirmed=%d err=%v", confirmed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetShowsGeneratedSecretOnce(t *testing.T) {
|
||||
s, err := newTestServer(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := s.cfg.SetAdmin("admin", "correct horse battery staple"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetupRoutes()
|
||||
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES ('u1','alice','alice@example.test','old',1,'2026-01-01T00:00:00Z')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminToken, csrf, err := s.createSession(sessionScopeAdmin, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.createSession(sessionScopeUser, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "csrf_token=" + csrf + "&action=reset-user-password&id=u1&password=correct+horse+battery+staple"
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/action", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrf})
|
||||
response := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/admin/password-result" {
|
||||
t.Fatalf("reset=%d %q: %s", response.Code, response.Header().Get("Location"), response.Body.String())
|
||||
}
|
||||
var sessions int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM server_sessions WHERE scope='user' AND subject_id='u1'").Scan(&sessions); err != nil || sessions != 0 {
|
||||
t.Fatalf("user sessions=%d err=%v", sessions, err)
|
||||
}
|
||||
resultRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
|
||||
resultRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
resultResponse := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(resultResponse, resultRequest)
|
||||
if resultResponse.Code != http.StatusOK || !strings.Contains(resultResponse.Header().Get("Cache-Control"), "no-store") || !strings.Contains(resultResponse.Body.String(), "one-time-secret") {
|
||||
t.Fatalf("password result=%d headers=%v body=%s", resultResponse.Code, resultResponse.Header(), resultResponse.Body.String())
|
||||
}
|
||||
secondRequest := httptest.NewRequest(http.MethodGet, "/admin/password-result", nil)
|
||||
secondRequest.AddCookie(&http.Cookie{Name: "admin_session", Value: adminToken})
|
||||
secondResponse := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(secondResponse, secondRequest)
|
||||
if secondResponse.Code != http.StatusSeeOther || secondResponse.Header().Get("Location") != "/admin/users" {
|
||||
t.Fatalf("second password result=%d %q", secondResponse.Code, secondResponse.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWebLocaleSystemUsesAcceptLanguageAndFallsBack(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
cfg.Web.DefaultLocale = "en"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -23,10 +25,15 @@ type webPage struct {
|
|||
DefaultLocale string
|
||||
Title string
|
||||
ServerName string
|
||||
PublicURL string
|
||||
TrustedProxies string
|
||||
Limits Limits
|
||||
CurrentPath string
|
||||
CurrentURL string
|
||||
CSRF string
|
||||
LocaleCSRF string
|
||||
Flash string
|
||||
FlashError bool
|
||||
AllowRegistration bool
|
||||
Version string
|
||||
BuildCommit string
|
||||
|
|
@ -37,9 +44,11 @@ type webPage struct {
|
|||
FormAction string
|
||||
BackURL string
|
||||
Token string
|
||||
OneTimeSecret string
|
||||
Admin bool
|
||||
UserName string
|
||||
Email string
|
||||
UserConfirmed bool
|
||||
Devices []webDevice
|
||||
AdminPage string
|
||||
Stats ServerStats
|
||||
|
|
@ -47,7 +56,9 @@ type webPage struct {
|
|||
AdminUsers []webAdminUser
|
||||
AdminDevices []webAdminDevice
|
||||
Vaults []webVault
|
||||
VaultDevices []webAdminDevice
|
||||
Audit []webAudit
|
||||
Warnings []string
|
||||
SMTP webSMTP
|
||||
List webList
|
||||
VaultDetail webVaultDetail
|
||||
|
|
@ -56,33 +67,50 @@ type webPage struct {
|
|||
type webAdminUser struct {
|
||||
ID, Username, Email, CreatedAt, LastSeen string
|
||||
Confirmed, Blocked bool
|
||||
Devices int
|
||||
Devices, Vaults int
|
||||
}
|
||||
type webAdminDevice struct {
|
||||
ID, Name, User, Vault, Version, LastSeen, CreatedAt string
|
||||
Revoked bool
|
||||
ID, Name, User, Vault, Version, LastIP, LastSeen, CreatedAt, TokenHint string
|
||||
Revoked bool
|
||||
}
|
||||
type webVault struct {
|
||||
User, UserID, Vault string
|
||||
Devices, Operations int
|
||||
LastActivity string
|
||||
}
|
||||
type webAudit struct{ Event, User, Device, At string }
|
||||
type webAudit struct{ Event, User, Device, IP, Message, Severity, At string }
|
||||
type webSMTP struct{ Host, Port, User, Security, From, ServerURL string }
|
||||
|
||||
type webList struct {
|
||||
Query string
|
||||
Status string
|
||||
Page int
|
||||
PerPage int
|
||||
Total int
|
||||
Pages int
|
||||
Previous int
|
||||
Next int
|
||||
Query, Status, Sort, User, Vault, Version, Event, Severity string
|
||||
Page int
|
||||
PerPage int
|
||||
Total int
|
||||
Pages int
|
||||
Previous int
|
||||
Next int
|
||||
}
|
||||
|
||||
func (list webList) params(page int) string {
|
||||
values := url.Values{}
|
||||
for key, value := range map[string]string{"q": list.Query, "status": list.Status, "sort": list.Sort, "user": list.User, "vault": list.Vault, "version": list.Version, "event": list.Event, "severity": list.Severity} {
|
||||
if value != "" {
|
||||
values.Set(key, value)
|
||||
}
|
||||
}
|
||||
if page > 1 {
|
||||
values.Set("page", strconvItoa(page))
|
||||
}
|
||||
if list.PerPage != 25 {
|
||||
values.Set("per_page", strconvItoa(list.PerPage))
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
type webVaultDetail struct {
|
||||
User, Vault, LastActivity string
|
||||
Devices, Operations int
|
||||
Devices, Active, Revoked int
|
||||
Operations, Sequence int
|
||||
BlobBytes int64
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +127,13 @@ type webDevice struct {
|
|||
|
||||
func newWebRenderer() (*webRenderer, error) {
|
||||
funcs := template.FuncMap{
|
||||
"t": func(locale, key string) string { return t(locale, key) },
|
||||
"t": func(locale, key string) string { return t(locale, key) },
|
||||
"webtime": func(locale, value string) string { return formatWebTime(locale, value) },
|
||||
"webbytes": func(value int64) string { return formatWebBytes(value) },
|
||||
"listparams": func(list webList, page int) string { return list.params(page) },
|
||||
"auditlabel": func(locale, event string) string { return auditEventLabel(locale, event) },
|
||||
"statuslabel": func(locale, status string) string { return statusLabel(locale, status) },
|
||||
"boollabel": func(locale string, value bool) string { return boolLabel(locale, value) },
|
||||
"short": func(value string, length int) string {
|
||||
if len(value) <= length || length < 5 {
|
||||
return value
|
||||
|
|
@ -107,12 +141,12 @@ func newWebRenderer() (*webRenderer, error) {
|
|||
return value[:length-1] + "…"
|
||||
},
|
||||
}
|
||||
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html")
|
||||
layout, err := template.New("layout.html").Funcs(funcs).ParseFS(webFS, "web/templates/layout.html", "web/templates/admin_nav.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
renderer := &webRenderer{templates: make(map[string]*template.Template)}
|
||||
for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail", "admin_settings"} {
|
||||
for _, page := range []string{"home", "unavailable", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin_dashboard", "admin_users", "admin_devices", "admin_vaults", "admin_storage", "admin_audit", "admin_diagnostics", "admin_create_user", "admin_password_result", "vault_detail", "admin_settings"} {
|
||||
clone, err := layout.Clone()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -129,6 +163,74 @@ func newWebRenderer() (*webRenderer, error) {
|
|||
return renderer, nil
|
||||
}
|
||||
|
||||
func formatWebTime(locale, value string) string {
|
||||
if value == "" {
|
||||
return "—"
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
if locale == "ru" {
|
||||
return parsed.Local().Format("02.01.2006 15:04")
|
||||
}
|
||||
return parsed.Local().Format("Jan 2, 2006 15:04")
|
||||
}
|
||||
|
||||
func formatWebBytes(value int64) string {
|
||||
if value < 1024 {
|
||||
return strconvItoa(int(value)) + " B"
|
||||
}
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
amount := float64(value)
|
||||
for _, unit := range units {
|
||||
amount /= 1024
|
||||
if amount < 1024 || unit == "TB" {
|
||||
return strconv.FormatFloat(amount, 'f', 1, 64) + " " + unit
|
||||
}
|
||||
}
|
||||
return "0 B"
|
||||
}
|
||||
|
||||
func auditEventLabel(locale, event string) string {
|
||||
keys := map[string]string{
|
||||
"device_auth_failed": "audit.deviceAuthFailed",
|
||||
"device_paired": "audit.devicePaired",
|
||||
"device_revoked": "audit.deviceRevoked",
|
||||
"device_deleted": "audit.deviceDeleted",
|
||||
"rate_limit_exceeded": "audit.rateLimited",
|
||||
"retention_cleanup": "audit.retentionCleanup",
|
||||
"smtp_settings_updated": "audit.smtpSettingsUpdated",
|
||||
"smtp_test_failed": "audit.smtpTestFailed",
|
||||
"smtp_test_passed": "audit.smtpTestPassed",
|
||||
"user_block_changed": "audit.userBlockChanged",
|
||||
"user_confirmed": "audit.userConfirmed",
|
||||
"user_created": "audit.userCreated",
|
||||
"user_deleted": "audit.userDeleted",
|
||||
"user_password_reset": "audit.userPasswordReset",
|
||||
"user_updated": "audit.userUpdated",
|
||||
"web_settings_updated": "audit.webSettingsUpdated",
|
||||
}
|
||||
if key := keys[event]; key != "" {
|
||||
return t(locale, key)
|
||||
}
|
||||
return t(locale, "audit.other")
|
||||
}
|
||||
|
||||
func statusLabel(locale, status string) string {
|
||||
if status == "ok" {
|
||||
return t(locale, "status.ok")
|
||||
}
|
||||
return t(locale, "status.degraded")
|
||||
}
|
||||
|
||||
func boolLabel(locale string, value bool) string {
|
||||
if value {
|
||||
return t(locale, "status.available")
|
||||
}
|
||||
return t(locale, "status.unavailable")
|
||||
}
|
||||
|
||||
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string, data webPage) {
|
||||
s.renderPageStatus(w, r, page, data, http.StatusOK)
|
||||
}
|
||||
|
|
@ -142,8 +244,12 @@ func (s *Server) renderPageStatus(w http.ResponseWriter, r *http.Request, page s
|
|||
data.LocalePreference = s.webLocalePreference(r)
|
||||
data.DefaultLocale = s.cfg.Web.DefaultLocale
|
||||
data.ServerName = s.cfg.Web.ServerName
|
||||
data.PublicURL = s.cfg.PublicURL
|
||||
data.TrustedProxies = strings.Join(s.cfg.TrustedProxies, ", ")
|
||||
data.Limits = s.cfg.Limits
|
||||
data.CurrentPath = r.URL.Path
|
||||
data.CurrentURL = r.URL.RequestURI()
|
||||
data.LocaleCSRF = s.webLocaleCSRF(w, r)
|
||||
data.AllowRegistration = s.cfg.Web.AllowRegistration
|
||||
data.Version = Version
|
||||
data.BuildCommit = BuildCommit
|
||||
|
|
@ -188,6 +294,10 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
status := s.healthStatus(r.Context())
|
||||
if status.Status != "ok" {
|
||||
s.renderPageStatus(w, r, "unavailable", webPage{Title: "home.unavailableTitle", Heading: "home.unavailableTitle", Message: "home.unavailableMessage"}, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
s.renderPage(w, r, "home", webPage{Title: "home.title", Status: status.Status})
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +310,10 @@ func (s *Server) handleLocale(w http.ResponseWriter, r *http.Request) {
|
|||
s.renderPage(w, r, "error", webPage{Title: "error.badRequest", Heading: "error.badRequest", Message: "error.tryAgain"})
|
||||
return
|
||||
}
|
||||
if !s.verifyWebLocaleCSRF(r) {
|
||||
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: "error.tryAgain", BackURL: "/"}, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
locale := r.FormValue("locale")
|
||||
if locale != "ru" && locale != "en" && locale != "system" {
|
||||
locale = "system"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -4,17 +4,21 @@ set -euo pipefail
|
|||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TMP="$(mktemp -d /tmp/verstak-sync-web.XXXXXX)"
|
||||
PORT="47794"
|
||||
cleanup() { kill "${SERVER_PID:-}" 2>/dev/null || true; rm -rf "$TMP"; }
|
||||
DEBUG_PORT="9223"
|
||||
cleanup() { kill "${BROWSER_PID:-}" 2>/dev/null || true; kill "${SERVER_PID:-}" 2>/dev/null || true; wait "${BROWSER_PID:-}" 2>/dev/null || true; wait "${SERVER_PID:-}" 2>/dev/null || true; if [[ "${KEEP_SMOKE_ARTIFACTS:-}" == "1" ]]; then printf 'web smoke artefacts retained at %s\n' "$TMP"; else rm -rf "$TMP"; fi; }
|
||||
trap cleanup EXIT
|
||||
|
||||
printf '%s\n' 'browser-smoke-admin-password' > "$TMP/admin-pass"
|
||||
chmod 600 "$TMP/admin-pass"
|
||||
(cd "$ROOT" && go run ./cmd/server --data "$TMP/data" --listen "127.0.0.1:$PORT" --admin-user admin --admin-pass-file "$TMP/admin-pass") >"$TMP/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null && break; sleep 1; done
|
||||
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 1; done
|
||||
curl --noproxy '*' -fsS "http://127.0.0.1:$PORT/" >/dev/null
|
||||
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-en.png" --window-size=1440,900 "http://127.0.0.1:$PORT/" >/dev/null
|
||||
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/login-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null
|
||||
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/admin-login.png" --window-size=1440,900 "http://127.0.0.1:$PORT/admin/login" >/dev/null
|
||||
test -s "$TMP/public-en.png" && test -s "$TMP/login-mobile.png" && test -s "$TMP/admin-login.png"
|
||||
echo "web browser smoke passed (screenshots were kept only for this run: $TMP)"
|
||||
chromium --headless --no-sandbox --disable-gpu --window-size=1440,900 --remote-debugging-port="$DEBUG_PORT" --user-data-dir="$TMP/chromium" about:blank >"$TMP/chromium.log" 2>&1 &
|
||||
BROWSER_PID=$!
|
||||
for _ in {1..30}; do curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null 2>&1 && break; sleep 1; done
|
||||
curl --noproxy '*' -fsS "http://127.0.0.1:$DEBUG_PORT/json/list" >/dev/null
|
||||
CHROME_DEBUG_URL="http://127.0.0.1:$DEBUG_PORT" node "$ROOT/scripts/smoke-web-browser.mjs" "http://127.0.0.1:$PORT" "$TMP"
|
||||
chromium --headless --no-sandbox --disable-gpu --screenshot="$TMP/public-mobile.png" --window-size=390,844 "http://127.0.0.1:$PORT/login" >/dev/null 2>&1
|
||||
test -s "$TMP/public-en.png" && test -s "$TMP/public-mobile.png" && test -s "$TMP/admin-dashboard.png" && test -s "$TMP/admin-settings.png"
|
||||
echo "interactive web browser smoke passed (set KEEP_SMOKE_ARTIFACTS=1 to retain temporary screenshots)"
|
||||
|
|
|
|||
Loading…
Reference in New Issue