diff --git a/internal/server/locale.go b/internal/server/locale.go
index ece628a..1caa10a 100644
--- a/internal/server/locale.go
+++ b/internal/server/locale.go
@@ -115,6 +115,7 @@ var _translations = map[string]map[string]string{
"admin.next": "Далее",
"admin.runCleanup": "Запустить безопасную очистку",
"admin.downloadDiagnostics": "Скачать диагностику",
+ "admin.vaultPrivacy": "Содержимое файлов и операции не отображаются в диагностике.",
"user.account": "Моя учётная запись",
"user.devices": "Подключённые устройства",
"user.noDevices": "Устройств пока нет",
@@ -353,6 +354,7 @@ var _translations = map[string]map[string]string{
"admin.next": "Next",
"admin.runCleanup": "Run safe cleanup",
"admin.downloadDiagnostics": "Download diagnostics",
+ "admin.vaultPrivacy": "File contents and operations are not displayed in diagnostics.",
"user.account": "My account",
"user.devices": "Connected devices",
"user.noDevices": "No devices yet",
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 6c00dee..e8dd06c 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -43,6 +43,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/admin/api/users/create", s.handleAdminAPICreateUser)
s.mux.HandleFunc("/admin/devices", s.handleAdminWeb)
s.mux.HandleFunc("/admin/vaults", s.handleAdminWeb)
+ s.mux.HandleFunc("/admin/vault/", s.handleAdminVaultDetail)
s.mux.HandleFunc("/admin/storage", s.handleAdminWeb)
s.mux.HandleFunc("/admin/audit", s.handleAdminWeb)
s.mux.HandleFunc("/admin/settings", s.handleAdminWeb)
diff --git a/internal/server/web/templates/admin.html b/internal/server/web/templates/admin.html
index bc319e9..7bef216 100644
--- a/internal/server/web/templates/admin.html
+++ b/internal/server/web/templates/admin.html
@@ -18,7 +18,7 @@
{{if eq .AdminPage "dashboard"}}
{{t .Locale "admin.overview"}}
{{t .Locale "admin.dashboard"}}
{{.Stats.Users}}{{t .Locale "admin.users"}}{{.Stats.ActiveDevices}}{{t .Locale "admin.activeDevices"}}{{.Stats.Vaults}}{{t .Locale "admin.vaults"}}{{.Stats.Operations}}{{t .Locale "admin.operations"}} {{t .Locale "admin.serviceHealth"}}
- {{t .Locale "admin.status"}}
- {{.Health.Status}}
- {{t .Locale "admin.version"}}
- {{.Health.Version}} {{.Health.BuildCommit}}
- {{t .Locale "admin.lastActivity"}}
- {{.Stats.LastSyncAt}}
{{else if eq .AdminPage "users"}}
{{else if eq .AdminPage "devices"}}{{t .Locale "admin.access"}}
{{t .Locale "admin.devices"}}
- {{else if eq .AdminPage "vaults"}}{{t .Locale "admin.storage"}}
{{t .Locale "admin.vaults"}}
+ {{else if eq .AdminPage "vaults"}}{{t .Locale "admin.storage"}}
{{t .Locale "admin.vaults"}}
{{else if eq .AdminPage "storage"}}{{t .Locale "admin.storage"}}
{{t .Locale "admin.storage"}}
{{.Stats.DatabaseBytes}}{{t .Locale "admin.databaseBytes"}}{{.Stats.BlobBytes}}{{t .Locale "admin.blobBytes"}}{{.Stats.Operations}}{{t .Locale "admin.operations"}} {{t .Locale "admin.retentionNote"}}
{{else if eq .AdminPage "audit"}}{{t .Locale "admin.diagnostics"}}
{{t .Locale "admin.audit"}}
{{else if eq .AdminPage "settings"}}{{t .Locale "admin.settings"}}
{{t .Locale "admin.smtpTitle"}}
diff --git a/internal/server/web/templates/vault_detail.html b/internal/server/web/templates/vault_detail.html
new file mode 100644
index 0000000..4a6a4ff
--- /dev/null
+++ b/internal/server/web/templates/vault_detail.html
@@ -0,0 +1,2 @@
+{{define "vault_detail"}}{{template "layout" .}}{{end}}
+{{define "content"}}{{t .Locale "admin.vaults"}}
{{short .VaultDetail.Vault 32}}
{{.VaultDetail.User}}
{{.VaultDetail.Devices}}{{t .Locale "admin.devices"}}{{.VaultDetail.Operations}}{{t .Locale "admin.operations"}}{{.VaultDetail.BlobBytes}}{{t .Locale "admin.blobBytes"}} {{end}}
diff --git a/internal/server/web_admin.go b/internal/server/web_admin.go
index 983cc17..a4c1f7a 100644
--- a/internal/server/web_admin.go
+++ b/internal/server/web_admin.go
@@ -21,6 +21,32 @@ func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
}
+func (s *Server) handleAdminVaultDetail(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ methodNotAllowed(w, http.MethodGet)
+ return
+ }
+ if !s.requireAdminCookie(w, r) {
+ return
+ }
+ userID, vaultID := r.URL.Query().Get("user"), r.URL.Query().Get("vault")
+ if userID == "" || vaultID == "" {
+ s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/vaults")
+ 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 {
+ jsonInternalError(w, err)
+ return
+ }
+ d.Vault = vaultID
+ if err := s.db.QueryRow(`SELECT COALESCE(SUM(size),0) FROM server_blob_refs WHERE user_id=? AND vault_id=?`, userID, vaultID).Scan(&d.BlobBytes); err != nil {
+ jsonInternalError(w, err)
+ return
+ }
+ s.renderPage(w, r, "vault_detail", webPage{Title: "admin.vaults", Admin: true, VaultDetail: d})
+}
+
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
if !s.requireAdminCookie(w, r) {
return
@@ -234,7 +260,7 @@ func (s *Server) webAdminDevices(list webList) ([]webAdminDevice, webList, error
}
func (s *Server) webVaults() ([]webVault, error) {
- rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`)
+ rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.user_id,d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`)
if err != nil {
return nil, err
}
@@ -242,7 +268,7 @@ func (s *Server) webVaults() ([]webVault, error) {
var out []webVault
for rows.Next() {
var v webVault
- if err := rows.Scan(&v.User, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil {
+ if err := rows.Scan(&v.User, &v.UserID, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil {
return nil, err
}
out = append(out, v)
diff --git a/internal/server/web_locale_test.go b/internal/server/web_locale_test.go
index bffc93a..1762a60 100644
--- a/internal/server/web_locale_test.go
+++ b/internal/server/web_locale_test.go
@@ -155,6 +155,38 @@ func TestWebSessionScopesDoNotCrossAuthorizePages(t *testing.T) {
}
}
+func TestAdminVaultDetailIsScopedAndDoesNotExposePayload(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','a@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)
+ }
+ if _, err := s.db.Exec("INSERT INTO server_ops (op_id,server_sequence,user_id,vault_id,device_id,entity_type,entity_id,op_type,payload_json,created_at,pushed_at) VALUES ('op1',1,'u1','vault-a','d1','file','x','create','{\"secret\":\"payload\"}','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')"); err != nil {
+ t.Fatal(err)
+ }
+ token, _, err := s.createSession(sessionScopeAdmin, "admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/admin/vault/?user=u1&vault=vault-a", nil)
+ req.AddCookie(&http.Cookie{Name: "admin_session", Value: token})
+ res := httptest.NewRecorder()
+ s.Handler().ServeHTTP(res, req)
+ if res.Code != http.StatusOK {
+ t.Fatalf("vault detail=%d: %s", res.Code, res.Body.String())
+ }
+ if strings.Contains(res.Body.String(), "payload") || strings.Contains(res.Body.String(), "secret") {
+ t.Fatalf("vault detail leaked operation payload: %s", res.Body.String())
+ }
+}
+
func TestLocaleSelectionUsesCookieAndPRG(t *testing.T) {
s, err := newTestServer(t)
if err != nil {
diff --git a/internal/server/web_render.go b/internal/server/web_render.go
index dc4aab0..924bf70 100644
--- a/internal/server/web_render.go
+++ b/internal/server/web_render.go
@@ -49,6 +49,7 @@ type webPage struct {
Audit []webAudit
SMTP webSMTP
List webList
+ VaultDetail webVaultDetail
}
type webAdminUser struct {
@@ -61,7 +62,7 @@ type webAdminDevice struct {
Revoked bool
}
type webVault struct {
- User, Vault string
+ User, UserID, Vault string
Devices, Operations int
LastActivity string
}
@@ -78,6 +79,11 @@ type webList struct {
Previous int
Next int
}
+type webVaultDetail struct {
+ User, Vault, LastActivity string
+ Devices, Operations int
+ BlobBytes int64
+}
type webDevice struct {
ID string
@@ -105,7 +111,7 @@ func newWebRenderer() (*webRenderer, error) {
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"} {
+ for _, page := range []string{"home", "login", "register", "forgot", "reset", "confirm", "message", "error", "admin_login", "dashboard", "admin", "admin_create_user", "vault_detail"} {
clone, err := layout.Clone()
if err != nil {
return nil, err