Add activity logs, sidebar badges, and logout with working alert colors.

Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
This commit is contained in:
2026-07-18 21:43:44 +02:00
parent e3793f380c
commit 463aa9a7a3
32 changed files with 3282 additions and 171 deletions
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"net/http"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type authLogsResponse struct {
Events []settings.AuthAuditEvent `json:"events"`
}
func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
filtered := make([]settings.AuthAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- {
filtered = append(filtered, store.Events[index])
}
writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered})
}
func appendAuthAudit(
configDir string,
action string,
outcome settings.AuthAuditOutcome,
category settings.AuthAuditCategory,
actor string,
target string,
detail string,
) {
eventID, err := auth.NewUUID()
if err != nil {
return
}
_ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: action,
Outcome: outcome,
Category: category,
Actor: actor,
Target: target,
Detail: detail,
})
}
func actorUsernameFromRequest(request *http.Request) string {
user, ok := UserFromContext(request.Context())
if !ok {
return ""
}
return user.Username
}
+211
View File
@@ -0,0 +1,211 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestAuthLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
// Strip logs.read and the permissions that would trigger migration.
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
failRec := httptest.NewRecorder()
router.ServeHTTP(failRec, failReq)
if failRec.Code != http.StatusUnauthorized {
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
}
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
okRec := httptest.NewRecorder()
router.ServeHTTP(okRec, okReq)
if okRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
}
var loginCookie *http.Cookie
for _, candidate := range okRec.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
loginCookie = candidate
break
}
}
if loginCookie == nil {
t.Fatal("login did not set session cookie")
}
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
),
loginCookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
}
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
}
var logs authLogsResponse
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) < 3 {
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
}
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
for _, event := range logs.Events {
switch {
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
sawLoginFailure = true
if event.Detail != "invalid credentials" {
t.Fatalf("failure detail = %q", event.Detail)
}
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
sawLoginSuccess = true
if event.Actor != "Admin" {
t.Fatalf("login actor = %q", event.Actor)
}
case event.Action == "user_create" && event.Target == "alice":
sawUserCreate = true
if event.Category != settings.AuthAuditCategoryUser {
t.Fatalf("category = %q", event.Category)
}
}
}
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
t.Fatalf(
"missing events: failure=%v success=%v create=%v events=%+v",
sawLoginFailure,
sawLoginSuccess,
sawUserCreate,
logs.Events,
)
}
// Newest first: user_create should appear before older login events.
if logs.Events[0].Action != "user_create" {
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
}
}
func TestMigrateLogsReadPermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
{Name: "hr", Permissions: []string{"users.manage"}},
{Name: "auditors", Permissions: []string{"logs.read"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateLogsReadPermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
t.Fatal("ops should gain logs.read from nodes.read")
}
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
t.Fatal("hr should gain logs.read from users.manage")
}
if len(payload.Groups[2].Permissions) != 1 {
t.Fatal("auditors should be unchanged")
}
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
t.Fatal("viewers should not gain logs.read")
}
if migrateLogsReadPermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
func containsPermission(permissions []string, wanted string) bool {
for _, permission := range permissions {
if permission == wanted {
return true
}
}
return false
}
+17
View File
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
var errActionsCreateRequired = errors.New("permission actions.create required") var errActionsCreateRequired = errors.New("permission actions.create required")
var errActionsUpdateRequired = errors.New("permission actions.update required") var errActionsUpdateRequired = errors.New("permission actions.update required")
var errActionsDeleteRequired = errors.New("permission actions.delete required") var errActionsDeleteRequired = errors.New("permission actions.delete required")
var errLogsReadRequired = errors.New("permission logs.read required")
// permissionsForUser returns the union of permissions from the user's groups. // permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} { func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
@@ -100,6 +101,22 @@ func (app *App) authorizeNodesRead(request *http.Request) error {
return nil return nil
} }
func (app *App) authorizeLogsRead(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "logs.read") {
return errLogsReadRequired
}
return nil
}
func (app *App) authorizeNodesExec(request *http.Request) error { func (app *App) authorizeNodesExec(request *http.Request) error {
user, ok := UserFromContext(request.Context()) user, ok := UserFromContext(request.Context())
if !ok { if !ok {
+23
View File
@@ -34,6 +34,7 @@ var allowedPermissions = map[string]struct{}{
"users.manage": {}, "users.manage": {},
"secrets.manage": {}, "secrets.manage": {},
"roles.manage": {}, "roles.manage": {},
"logs.read": {},
} }
func groupsGetHandler(configDir string) http.HandlerFunc { func groupsGetHandler(configDir string) http.HandlerFunc {
@@ -89,6 +90,19 @@ func groupsUpsertHandler(configDir string) http.HandlerFunc {
return return
} }
action := "group_create"
if updated {
action = "group_update"
}
appendAuthAudit(
configDir,
action,
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryGroup,
actorUsernameFromRequest(request),
payload.Group.Name,
"",
)
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups}) writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
} }
} }
@@ -137,6 +151,15 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
return return
} }
appendAuthAudit(
configDir,
"group_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryGroup,
actorUsernameFromRequest(request),
name,
"",
)
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups}) writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
} }
} }
+27
View File
@@ -140,6 +140,15 @@ func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Requ
_ = app.Sessions.DestroySession(writer, request) _ = app.Sessions.DestroySession(writer, request)
} }
appendAuthAudit(
app.ConfigDir,
"password_change",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
} }
@@ -257,6 +266,15 @@ func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.R
return return
} }
appendAuthAudit(
app.ConfigDir,
"totp_enable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
} }
@@ -319,5 +337,14 @@ func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.R
return return
} }
appendAuthAudit(
app.ConfigDir,
"totp_disable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
} }
+5 -2
View File
@@ -10,6 +10,8 @@ import (
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
) )
const sessionIdleExemptHeader = "X-Session-Idle-Exempt"
func (app *App) withMiddleware(next http.Handler) http.Handler { func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path path := request.URL.Path
@@ -75,7 +77,8 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
} }
security := effectiveSecurity(settingsPayload.Security) security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security) touchActivity := request.Header.Get(sessionIdleExemptHeader) != "1"
session, err := app.Sessions.LookupValidSession(writer, request, security, touchActivity)
if err != nil { if err != nil {
errorMessage := "authentication required" errorMessage := "authentication required"
if errors.Is(err, auth.ErrSessionIdle) { if errors.Is(err, auth.ErrSessionIdle) {
@@ -142,7 +145,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
writer.Header().Set("Vary", "Origin") writer.Header().Set("Vary", "Origin")
} }
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+sessionIdleExemptHeader)
if request.Method == http.MethodOptions { if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent) writer.WriteHeader(http.StatusNoContent)
@@ -81,3 +81,129 @@ func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) {
t.Fatalf("expected authentication required, got %q", payload.Error) t.Fatalf("expected authentication required, got %q", payload.Error)
} }
} }
func TestMiddlewareIdleExemptDoesNotAdvanceLastSeenAt(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
request.Header.Set(sessionIdleExemptHeader, "1")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
store, err = settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions after request: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt %v unchanged, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
func TestMiddlewareIdleExemptStillReturnsSessionIdle(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
request.Header.Set(sessionIdleExemptHeader, "1")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "session_idle" {
t.Fatalf("expected session_idle, got %q", payload.Error)
}
}
func TestMiddlewareNormalRequestAdvancesLastSeenAt(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
store, err = settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions after request: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.After(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt after %v, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
+1 -46
View File
@@ -450,50 +450,24 @@ func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Req
} }
func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) { func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(request); err != nil { if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return return
} }
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir) store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir)
if err != nil { if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return return
} }
nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodeByID := make(map[string]settings.Node, len(nodesStore.Nodes))
for _, node := range nodesStore.Nodes {
nodeByID[node.ID] = node
}
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind")) kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
isAdmin := userIsAdministrator(user)
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events)) filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- { for index := len(store.Events) - 1; index >= 0; index-- {
event := store.Events[index] event := store.Events[index]
if kindFilter != "" && string(event.NodeKind) != kindFilter { if kindFilter != "" && string(event.NodeKind) != kindFilter {
continue continue
} }
if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) {
continue
}
filtered = append(filtered, event) filtered = append(filtered, event)
} }
@@ -609,25 +583,6 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
return false return false
} }
func userCanSeeNodeAuditEvent(
user settings.UserCredential,
groups []settings.Group,
nodeByID map[string]settings.Node,
event settings.NodeAuditEvent,
isAdmin bool,
) bool {
if isAdmin {
return true
}
if event.Actor == user.Username {
return true
}
if node, ok := nodeByID[event.NodeID]; ok {
return userCanAccessNode(user, groups, node)
}
return false
}
func appendNodeAudit( func appendNodeAudit(
configDir string, configDir string,
action settings.NodeAuditAction, action settings.NodeAuditAction,
@@ -644,3 +644,84 @@ func TestNodeLogsListFiltersByKind(t *testing.T) {
} }
} }
} }
func TestNodeLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestNodeLogsAllowsLogsReadWithoutNodesRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if err := settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: settings.NodeAuditActionCreate,
Actor: "Admin",
NodeID: "node-1",
NodeName: "test-node",
NodeKind: settings.NodeKindContainer,
}); err != nil {
t.Fatalf("AppendNodeAuditEvent: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var logs nodeLogsResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(logs.Events))
}
}
+1
View File
@@ -56,6 +56,7 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler) mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler) mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler) mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
return app.withCORS(app.withMiddleware(mux)) return app.withCORS(app.withMiddleware(mux))
} }
@@ -360,13 +360,32 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
break break
} }
} }
attemptedUsername := strings.TrimSpace(payload.Username)
if matched == nil || !matched.Enabled { if matched == nil || !matched.Enabled {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
attemptedUsername,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return return
} }
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash) ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok { if err != nil || !ok {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
matched.Username,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return return
} }
@@ -381,6 +400,15 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != "" requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP { if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) { if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"invalid or missing TOTP code",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"}) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return return
} }
@@ -391,13 +419,34 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
return return
} }
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"",
)
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload)) writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
} }
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) { func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
actor := actorUsernameFromRequest(request)
if app.Sessions != nil { if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request) _ = app.Sessions.DestroySession(writer, request)
} }
if actor != "" {
appendAuthAudit(
app.ConfigDir,
"logout",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
actor,
actor,
"",
)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
} }
@@ -459,6 +508,7 @@ func allPermissionList() []string {
"users.manage", "users.manage",
"secrets.manage", "secrets.manage",
"roles.manage", "roles.manage",
"logs.read",
} }
_ = list _ = list
return ordered return ordered
@@ -484,5 +534,35 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload.Groups = []settings.Group{} payload.Groups = []settings.Group{}
} }
payload.Network = settings.EffectiveNetwork(payload.Network) payload.Network = settings.EffectiveNetwork(payload.Network)
if migrateLogsReadPermission(&payload) {
if err := settings.SaveSettings(configDir, payload); err != nil {
return settings.Settings{}, err
}
}
return payload, nil return payload, nil
} }
// migrateLogsReadPermission grants logs.read to groups that previously could
// view logs via nodes.read or users.manage. Returns true when settings changed.
func migrateLogsReadPermission(payload *settings.Settings) bool {
changed := false
for index := range payload.Groups {
group := &payload.Groups[index]
hasLogsRead := false
hadLogAccess := false
for _, permission := range group.Permissions {
switch permission {
case "logs.read":
hasLogsRead = true
case "nodes.read", "users.manage":
hadLogAccess = true
}
}
if hasLogsRead || !hadLogAccess {
continue
}
group.Permissions = append(group.Permissions, "logs.read")
changed = true
}
return changed
}
+29
View File
@@ -137,6 +137,15 @@ func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Req
return return
} }
appendAuthAudit(
app.ConfigDir,
"user_create",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
} }
@@ -254,6 +263,15 @@ func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Requ
_ = app.Sessions.InvalidateUserSessions(userID) _ = app.Sessions.InvalidateUserSessions(userID)
} }
appendAuthAudit(
app.ConfigDir,
"user_update",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
target.Username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
} }
@@ -293,12 +311,14 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
adminCount := countAdministrators(store.Users) adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users)) remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false found := false
deletedUsername := ""
for _, user := range store.Users { for _, user := range store.Users {
if user.ID != userID { if user.ID != userID {
remaining = append(remaining, user) remaining = append(remaining, user)
continue continue
} }
found = true found = true
deletedUsername = user.Username
if userIsAdministrator(user) && adminCount <= 1 { if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{ writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator", Error: "cannot delete the last administrator",
@@ -322,6 +342,15 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
_ = app.Sessions.InvalidateUserSessions(userID) _ = app.Sessions.InvalidateUserSessions(userID)
} }
appendAuthAudit(
app.ConfigDir,
"user_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
deletedUsername,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)}) writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
} }
+9 -1
View File
@@ -87,11 +87,15 @@ func (manager *SessionManager) CreateSession(
return record, nil return record, nil
} }
// LookupValidSession finds a non-expired, non-idle session and may rotate it. // LookupValidSession finds a non-expired, non-idle session.
// When touchActivity is true, LastSeenAt is updated and the session may rotate.
// When touchActivity is false, the session is validated only (no LastSeenAt update,
// rotation, or sessions file write unless the session is being destroyed as idle/expired).
func (manager *SessionManager) LookupValidSession( func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
security settings.SecuritySettings, security settings.SecuritySettings,
touchActivity bool,
) (settings.SessionRecord, error) { ) (settings.SessionRecord, error) {
cookie, err := request.Cookie(SessionCookieName) cookie, err := request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" { if err != nil || strings.TrimSpace(cookie.Value) == "" {
@@ -140,6 +144,10 @@ func (manager *SessionManager) LookupValidSession(
return settings.SessionRecord{}, ErrSessionNotFound return settings.SessionRecord{}, ErrSessionNotFound
} }
if !touchActivity {
return *found, nil
}
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter || shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
now.Sub(found.CreatedAt) >= lifetime/2 now.Sub(found.CreatedAt) >= lifetime/2
+86 -3
View File
@@ -39,7 +39,7 @@ func TestLookupValidSessionReturnsIdle(t *testing.T) {
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID}) request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder() lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security) _, err = manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionIdle) { if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err) t.Fatalf("expected ErrSessionIdle, got %v", err)
} }
@@ -61,7 +61,7 @@ func TestLookupValidSessionMissingCookie(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
lookupRec := httptest.NewRecorder() lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security) _, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) { if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err) t.Fatalf("expected ErrSessionNotFound, got %v", err)
} }
@@ -76,12 +76,95 @@ func TestLookupValidSessionUnknownCookie(t *testing.T) {
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"}) request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
lookupRec := httptest.NewRecorder() lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security) _, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) { if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err) t.Fatalf("expected ErrSessionNotFound, got %v", err)
} }
} }
func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 30
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
got, err := manager.LookupValidSession(lookupRec, request, security, false)
if err != nil {
t.Fatalf("LookupValidSession: %v", err)
}
if got.ID != record.ID {
t.Fatalf("expected session id %q, got %q", record.ID, got.ID)
}
store, err = settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions after lookup: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt %v unchanged, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security, false)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
}
func testSessionKey() []byte { func testSessionKey() []byte {
key := make([]byte, 32) key := make([]byte, 32)
for index := range key { for index := range key {
+77
View File
@@ -0,0 +1,77 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// MaxAuthAuditEvents is the retention cap for auth-audit.json.
const MaxAuthAuditEvents = 1000
// LoadAuthAudit reads and parses auth-audit.json from dir.
func LoadAuthAudit(dir string) (AuthAuditStore, error) {
path := filepath.Join(dir, AuthAuditFileName)
payload, err := os.ReadFile(path)
if err != nil {
return AuthAuditStore{}, err
}
var store AuthAuditStore
if err := json.Unmarshal(payload, &store); err != nil {
return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err)
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
return store, nil
}
// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing.
func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) {
store, err := LoadAuthAudit(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return AuthAuditStore{Events: []AuthAuditEvent{}}, nil
}
return AuthAuditStore{}, err
}
return store, nil
}
// SaveAuthAudit writes auth-audit.json to dir with mode 0640.
func SaveAuthAudit(dir string, store AuthAuditStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode auth audit: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, AuthAuditFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write auth audit: %w", err)
}
return nil
}
// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept).
func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error {
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
return err
}
store.Events = append(store.Events, event)
if len(store.Events) > MaxAuthAuditEvents {
store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:]
}
return SaveAuthAudit(dir, store)
}
@@ -0,0 +1,81 @@
package settings
import (
"testing"
"time"
)
func TestAuthAuditRoundTripAndCap(t *testing.T) {
dir := t.TempDir()
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
t.Fatalf("LoadAuthAuditOrEmpty: %v", err)
}
if len(store.Events) != 0 {
t.Fatalf("expected empty, got %d", len(store.Events))
}
event := AuthAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}
if err := AppendAuthAuditEvent(dir, event); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" {
t.Fatalf("loaded = %+v", loaded)
}
if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess {
t.Fatalf("outcome = %q", loaded.Events[0].Outcome)
}
}
func TestAppendAuthAuditEventTrimsToCap(t *testing.T) {
dir := t.TempDir()
originalCap := MaxAuthAuditEvents
store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)}
for index := 0; index < MaxAuthAuditEvents+5; index++ {
store.Events = append(store.Events, AuthAuditEvent{
ID: "evt",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
Detail: string(rune('a' + (index % 26))),
})
}
if err := SaveAuthAudit(dir, store); err != nil {
t.Fatalf("SaveAuthAudit: %v", err)
}
if err := AppendAuthAuditEvent(dir, AuthAuditEvent{
ID: "newest",
At: time.Now().UTC(),
Action: "logout",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != originalCap {
t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap)
}
if loaded.Events[len(loaded.Events)-1].ID != "newest" {
t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID)
}
}
+1
View File
@@ -19,6 +19,7 @@ const (
NodesFileName = "nodes.json" NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc" NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json" NodeAuditFileName = "node-audit.json"
AuthAuditFileName = "auth-audit.json"
ActionsFileName = "actions.json" ActionsFileName = "actions.json"
) )
+35
View File
@@ -186,6 +186,41 @@ type NodeAuditStore struct {
Events []NodeAuditEvent `json:"events"` Events []NodeAuditEvent `json:"events"`
} }
// AuthAuditOutcome is success or failure for an auth/user/group audit event.
type AuthAuditOutcome string
const (
AuthAuditOutcomeSuccess AuthAuditOutcome = "success"
AuthAuditOutcomeFailure AuthAuditOutcome = "failure"
)
// AuthAuditCategory groups auth audit events for UI styling and filtering.
type AuthAuditCategory string
const (
AuthAuditCategoryAuth AuthAuditCategory = "auth"
AuthAuditCategoryUser AuthAuditCategory = "user"
AuthAuditCategoryGroup AuthAuditCategory = "group"
AuthAuditCategorySelf AuthAuditCategory = "self"
)
// AuthAuditEvent is one append-only record of a login or user/group change.
type AuthAuditEvent struct {
ID string `json:"id"`
At time.Time `json:"at"`
Action string `json:"action"`
Outcome AuthAuditOutcome `json:"outcome"`
Category AuthAuditCategory `json:"category"`
Actor string `json:"actor"`
Target string `json:"target,omitempty"`
Detail string `json:"detail,omitempty"`
}
// AuthAuditStore is the plain JSON payload in auth-audit.json.
type AuthAuditStore struct {
Events []AuthAuditEvent `json:"events"`
}
// UserCredential is one account stored in passwords.enc. // UserCredential is one account stored in passwords.enc.
type UserCredential struct { type UserCredential struct {
ID string `json:"id"` ID string `json:"id"`
+224 -3
View File
@@ -1,6 +1,8 @@
.app-shell { .app-shell {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
height: 100vh;
overflow: hidden;
color: var(--ctp-text); color: var(--ctp-text);
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
} }
@@ -86,6 +88,18 @@
border-top: 1px solid var(--ctp-surface0); border-top: 1px solid var(--ctp-surface0);
} }
.sidebar-footer-profile-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.sidebar-footer-profile-row .profile-item {
flex: 1;
min-width: 0;
width: auto;
}
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -119,13 +133,51 @@
font-weight: 600; font-weight: 600;
} }
.nav-item-label {
min-width: 0;
}
.nav-count-badge {
margin-left: auto;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
min-height: 1.35rem;
padding: 0.1rem 0.35rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
font-size: 0.7rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.nav-count-badge-alert {
border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1));
background: color-mix(in srgb, var(--ctp-red) 14%, transparent);
color: var(--ctp-red);
}
.config-item, .config-item,
.profile-item { .profile-item {
font-weight: 500; font-weight: 500;
} }
.sidebar-footer-profile-row .logout-item {
flex: 0 0 auto;
width: fit-content;
min-width: 0;
justify-content: center;
padding: 0.6rem 0.35rem;
}
.cog-icon, .cog-icon,
.user-icon { .user-icon,
.logout-icon {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -237,6 +289,14 @@
flex: 1; flex: 1;
padding: 2rem 2.5rem; padding: 2rem 2.5rem;
min-width: 0; min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.content:has(.logs-panel) {
overflow: hidden;
} }
.content h2 { .content h2 {
@@ -244,6 +304,7 @@
font-size: 1.75rem; font-size: 1.75rem;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--ctp-text); color: var(--ctp-text);
flex-shrink: 0;
} }
.content p { .content p {
@@ -1106,7 +1167,101 @@
.logs-panel { .logs-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.75rem;
flex: 1;
min-height: 0;
overflow: hidden;
}
.logs-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
align-items: end;
}
.logs-filters-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
appearance: none;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-surface0);
color: var(--ctp-text);
padding: 0.45rem 0.7rem;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
align-self: end;
}
.logs-filters-toggle:hover {
border-color: var(--ctp-overlay0);
background: var(--ctp-surface1);
}
.logs-filters-toggle-open {
border-color: var(--ctp-overlay0);
background: var(--ctp-surface1);
}
.logs-filters-chevron {
flex-shrink: 0;
transition: transform 160ms ease;
transform: rotate(0deg);
}
.logs-filters-chevron-expanded {
transform: rotate(180deg);
}
.logs-filters-panel {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding: 0.75rem;
border: 1px solid var(--ctp-surface0);
border-radius: 0.35rem;
background: color-mix(in srgb, var(--ctp-mantle) 70%, var(--ctp-base));
}
.logs-search-label,
.logs-page-size-label,
.logs-date-custom label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--ctp-subtext0);
}
.logs-search-input {
min-width: min(100%, 18rem);
padding: 0.45rem 0.65rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-base);
color: var(--ctp-text);
font: inherit;
}
.logs-page-size-label select,
.logs-date-custom input {
padding: 0.45rem 0.65rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.35rem;
background: var(--ctp-base);
color: var(--ctp-text);
font: inherit;
}
.logs-date-custom {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
} }
.logs-filters { .logs-filters {
@@ -1115,6 +1270,14 @@
gap: 0.35rem; gap: 0.35rem;
} }
.logs-table-scroll {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--ctp-surface0);
border-radius: 0.35rem;
}
.logs-table { .logs-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -1129,12 +1292,70 @@
vertical-align: top; vertical-align: top;
} }
.logs-table th { .logs-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--ctp-mantle);
font-weight: 600; font-weight: 600;
color: var(--ctp-subtext0); color: var(--ctp-subtext0);
font-size: 0.8rem; font-size: 0.8rem;
} }
.logs-sort-button {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
padding: 0;
cursor: pointer;
text-align: left;
}
.logs-sort-button:hover {
color: var(--ctp-text);
}
.logs-table code { .logs-table code {
font-size: 0.85em; font-size: 0.85em;
} }
.logs-table tr.logs-row-failure {
background: color-mix(in srgb, var(--ctp-red) 18%, var(--ctp-base));
}
.logs-table tr.logs-row-success {
color: var(--ctp-green);
}
.logs-table tr.logs-row-self {
color: color-mix(in srgb, var(--ctp-blue) 70%, var(--ctp-text));
}
.logs-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-shrink: 0;
}
.logs-pagination-label,
.logs-pagination-page {
font-size: 0.85rem;
color: var(--ctp-subtext0);
}
.logs-pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logs-pagination-controls button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
+415 -6
View File
@@ -1,13 +1,11 @@
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App' import App from './App'
import { THEME_STORAGE_KEY } from './theme' import { THEME_STORAGE_KEY } from './theme'
function stubFetchOk() { function stubFetchOk() {
vi.stubGlobal( const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString() const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) { if (url.includes('/api/v1/setup/status')) {
@@ -17,6 +15,13 @@ function stubFetchOk() {
}) })
} }
if (url.includes('/api/v1/auth/logout')) {
return Promise.resolve({
ok: true,
json: async () => ({ ok: true }),
})
}
if (url.includes('/api/v1/auth/me')) { if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -39,6 +44,7 @@ function stubFetchOk() {
'users.manage', 'users.manage',
'secrets.manage', 'secrets.manage',
'roles.manage', 'roles.manage',
'logs.read',
], ],
}), }),
}) })
@@ -61,6 +67,13 @@ function stubFetchOk() {
}) })
} }
if (url.includes('/api/v1/node-logs') || url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/groups')) { if (url.includes('/api/v1/groups')) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -83,6 +96,7 @@ function stubFetchOk() {
'users.manage', 'users.manage',
'secrets.manage', 'secrets.manage',
'roles.manage', 'roles.manage',
'logs.read',
], ],
}, },
], ],
@@ -194,8 +208,10 @@ function stubFetchOk() {
ok: true, ok: true,
json: async () => ({}), json: async () => ({}),
}) })
}), })
)
vi.stubGlobal('fetch', fetchMock)
return fetchMock
} }
function stubMatchMedia(prefersDark: boolean) { function stubMatchMedia(prefersDark: boolean) {
@@ -226,6 +242,7 @@ describe('App', () => {
localStorage.clear() localStorage.clear()
delete document.documentElement.dataset.theme delete document.documentElement.dataset.theme
window.history.replaceState(null, '', '/') window.history.replaceState(null, '', '/')
vi.useRealTimers()
vi.unstubAllGlobals() vi.unstubAllGlobals()
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -248,6 +265,7 @@ describe('App', () => {
expect( expect(
screen.getByRole('button', { name: 'Containers' }), screen.getByRole('button', { name: 'Containers' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument()
expect( expect(
screen.getByRole('button', { name: 'Configuration' }), screen.getByRole('button', { name: 'Configuration' }),
).toBeInTheDocument() ).toBeInTheDocument()
@@ -259,6 +277,370 @@ describe('App', () => {
expect(screen.getByText('Coming soon')).toBeInTheDocument() expect(screen.getByText('Coming soon')).toBeInTheDocument()
}) })
it('shows node count badges on resource nav items', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: [
'nodes.read',
'logs.read',
],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({
nodes: [
{ id: 'c1', kind: 'container', name: 'alpha' },
{ id: 'c2', kind: 'container', name: 'beta' },
{ id: 'd1', kind: 'docker', name: 'dock' },
],
}),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
}),
)
render(<App />)
const containersButton = await screen.findByRole('button', {
name: 'Containers',
})
await waitFor(() => {
expect(
containersButton.querySelector('.nav-count-badge'),
).toHaveTextContent('2')
})
expect(
screen
.getByRole('button', { name: 'Docker' })
.querySelector('.nav-count-badge'),
).toHaveTextContent('1')
expect(
screen
.getByRole('button', { name: 'Virtual Machines' })
.querySelector('.nav-count-badge'),
).toHaveTextContent('0')
expect(
screen
.getByRole('button', { name: 'Activity' })
.querySelector('.nav-count-badge-alert'),
).toBeNull()
})
it('shows a red Activity badge for auth failures today', async () => {
const todayIso = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate(),
12,
0,
0,
).toISOString()
const yesterdayIso = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate() - 1,
12,
0,
0,
).toISOString()
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'logs.read'],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({
events: [
{
id: 'a1',
at: todayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'eve',
},
{
id: 'a2',
at: todayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'mallory',
},
{
id: 'a3',
at: yesterdayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'old',
},
{
id: 'a4',
at: todayIso,
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
},
],
}),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({ nodes: [] }),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
}),
)
render(<App />)
const activityButton = await screen.findByRole('button', {
name: 'Activity',
})
const alertBadge = await waitFor(() => {
const badge = activityButton.querySelector('.nav-count-badge-alert')
expect(badge).not.toBeNull()
return badge as Element
})
expect(alertBadge).toHaveTextContent('2')
})
it('polls sidebar counts every 30s with idle-exempt header', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'logs.read'],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({ nodes: [] }),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
})
vi.stubGlobal('fetch', fetchMock)
render(<App />)
await screen.findByRole('heading', { name: 'Cluster Canvas' })
await waitFor(() => {
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).includes('/api/v1/nodes'),
),
).toBe(true)
})
const nodesCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/nodes'),
).length
const authCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/auth-logs'),
).length
await vi.advanceTimersByTimeAsync(30_000)
await waitFor(() => {
const nodesCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/nodes'),
)
expect(nodesCalls.length).toBeGreaterThan(nodesCallsBeforePoll)
})
const pollNodesCall = [...fetchMock.mock.calls]
.reverse()
.find(([input]) => String(input).includes('/api/v1/nodes'))
expect(pollNodesCall).toBeDefined()
const pollNodesHeaders = (pollNodesCall?.[1] as { headers: Headers }).headers
expect(pollNodesHeaders.get('X-Session-Idle-Exempt')).toBe('1')
const pollAuthCall = [...fetchMock.mock.calls]
.reverse()
.find(([input]) => String(input).includes('/api/v1/auth-logs'))
expect(pollAuthCall).toBeDefined()
const pollAuthHeaders = (pollAuthCall?.[1] as { headers: Headers }).headers
expect(pollAuthHeaders.get('X-Session-Idle-Exempt')).toBe('1')
expect(authCallsBeforePoll).toBeGreaterThan(0)
vi.useRealTimers()
})
it('hides Activity logs when logs.read is missing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'users.manage'],
}),
})
}
if (url.includes('/health') || url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'ok', service: 'clustercanvas', version: '0.1.0' }),
})
}
return Promise.resolve({ ok: true, json: async () => ({}) })
}),
)
render(<App />)
expect(
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Activity' }),
).not.toBeInTheDocument()
})
it('opens Profile from the sidebar and updates the URL', async () => { it('opens Profile from the sidebar and updates the URL', async () => {
const user = userEvent.setup() const user = userEvent.setup()
stubFetchOk() stubFetchOk()
@@ -281,6 +663,32 @@ describe('App', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('logs out from the sidebar and shows a signed-out notice', async () => {
const user = userEvent.setup()
const fetchMock = stubFetchOk()
render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Log out' }))
expect(
await screen.findByRole('heading', { name: 'Sign in' }),
).toBeInTheDocument()
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
expect(window.location.pathname).toBe('/login')
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).includes('/api/v1/auth/logout') &&
(init as RequestInit | undefined)?.method === 'POST',
),
).toBe(true)
})
it('restores Profile from the URL on load', async () => { it('restores Profile from the URL on load', async () => {
stubFetchOk() stubFetchOk()
window.history.replaceState(null, '', '/profile') window.history.replaceState(null, '', '/profile')
@@ -423,6 +831,7 @@ describe('App', () => {
'users.manage', 'users.manage',
'secrets.manage', 'secrets.manage',
'roles.manage', 'roles.manage',
'logs.read',
]) { ]) {
expect(screen.getByText(permission)).toBeInTheDocument() expect(screen.getByText(permission)).toBeInTheDocument()
} }
+730 -49
View File
@@ -14,9 +14,11 @@ import {
DEFAULT_SECURITY_SETTINGS, DEFAULT_SECURITY_SETTINGS,
disableMyTOTP, disableMyTOTP,
fetchActions, fetchActions,
fetchAuthLogs,
fetchGroups, fetchGroups,
fetchMe, fetchMe,
fetchNetwork, fetchNetwork,
logout,
fetchNodeLogs, fetchNodeLogs,
fetchNodes, fetchNodes,
fetchSecurity, fetchSecurity,
@@ -32,11 +34,13 @@ import {
type Action, type Action,
type ActionEnvVar, type ActionEnvVar,
type ActionKind, type ActionKind,
type AuthAuditEvent,
type Group, type Group,
type GroupScopeKind, type GroupScopeKind,
type MeResponse, type MeResponse,
type NetworkSettings, type NetworkSettings,
type Node, type Node,
type NodeAuditAction,
type NodeAuditEvent, type NodeAuditEvent,
type NodeKind, type NodeKind,
type ReauthCredentials, type ReauthCredentials,
@@ -46,7 +50,34 @@ import {
saveSecurity, saveSecurity,
upsertGroup, upsertGroup,
} from './api/client' } from './api/client'
import {
filterAuthEvents,
filterNodeEvents,
pageRangeLabel,
paginateItems,
resolveDateRangeBounds,
sortAuthEvents,
sortNodeEvents,
type AuthActionQuickFilter,
type AuthCategoryFilter,
type AuthLogsSortKey,
type AuthOutcomeFilter,
type DateRangePreset,
type NodeLogsSortKey,
type SortDirection,
} from './logsFiltering'
import {
LOGS_PAGE_SIZE_OPTIONS,
readLogsPageSize,
writeLogsPageSize,
type LogsPageSize,
} from './logsPreferences'
import { LoginPage } from './LoginPage' import { LoginPage } from './LoginPage'
import {
countAuthFailuresToday,
countNodesBySection,
type NodeSectionCounts,
} from './sidebarCounts'
import { import {
isResourceSectionId, isResourceSectionId,
navigateTo, navigateTo,
@@ -130,6 +161,7 @@ const DEFAULT_PERMISSIONS = [
'users.manage', 'users.manage',
'secrets.manage', 'secrets.manage',
'roles.manage', 'roles.manage',
'logs.read',
] as const ] as const
const ADMINISTRATORS_GROUP_NAME = 'Administrators' const ADMINISTRATORS_GROUP_NAME = 'Administrators'
@@ -178,6 +210,46 @@ function UserIcon() {
) )
} }
function LogoutIcon() {
return (
<svg
className="logout-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5-5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"
/>
</svg>
)
}
function FiltersChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={
expanded
? 'logs-filters-chevron logs-filters-chevron-expanded'
: 'logs-filters-chevron'
}
viewBox="0 0 24 24"
width="16"
height="16"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"
/>
</svg>
)
}
function ProfilePanel({ function ProfilePanel({
onSignedOut, onSignedOut,
}: { }: {
@@ -2954,35 +3026,93 @@ function ResourceSectionPanel({
} }
function LogsPanel() { function LogsPanel() {
type LogsSection = 'nodes' | 'auth'
type KindFilter = 'all' | NodeKind type KindFilter = 'all' | NodeKind
type NodeActionFilter = 'all' | NodeAuditAction
const [logsSection, setLogsSection] = useState<LogsSection>('nodes')
const [kindFilter, setKindFilter] = useState<KindFilter>('all') const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [events, setEvents] = useState<ReadonlyArray<NodeAuditEvent>>([]) const [nodeActionFilter, setNodeActionFilter] =
useState<NodeActionFilter>('all')
const [authOutcomeFilter, setAuthOutcomeFilter] =
useState<AuthOutcomeFilter>('all')
const [authCategoryFilter, setAuthCategoryFilter] =
useState<AuthCategoryFilter>('all')
const [authActionFilter, setAuthActionFilter] =
useState<AuthActionQuickFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [datePreset, setDatePreset] = useState<DateRangePreset>('all')
const [customDateFrom, setCustomDateFrom] = useState('')
const [customDateTo, setCustomDateTo] = useState('')
const [pageSize, setPageSize] = useState<LogsPageSize>(() =>
readLogsPageSize(),
)
const [filtersExpanded, setFiltersExpanded] = useState(false)
const [page, setPage] = useState(1)
const [nodeSortKey, setNodeSortKey] = useState<NodeLogsSortKey>('at')
const [authSortKey, setAuthSortKey] = useState<AuthLogsSortKey>('at')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const [nodeEvents, setNodeEvents] = useState<ReadonlyArray<NodeAuditEvent>>(
[],
)
const [authEvents, setAuthEvents] = useState<ReadonlyArray<AuthAuditEvent>>(
[],
)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [canRead, setCanRead] = useState(false) const [canReadLogs, setCanReadLogs] = useState(false)
const [permissionsReady, setPermissionsReady] = useState(false)
useEffect(() => { useEffect(() => {
let isCancelled = false let isCancelled = false
async function loadPermissions() {
try {
const me = await fetchMe()
if (isCancelled) {
return
}
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
} catch {
if (!isCancelled) {
setCanReadLogs(false)
}
} finally {
if (!isCancelled) {
setPermissionsReady(true)
}
}
}
void loadPermissions()
return () => {
isCancelled = true
}
}, [])
useEffect(() => {
if (!permissionsReady || !canReadLogs) {
return
}
let isCancelled = false
async function load() { async function load() {
setIsLoading(true) setIsLoading(true)
setLoadError(null) setLoadError(null)
try { try {
const me = await fetchMe() if (logsSection === 'nodes') {
if (isCancelled) { const response = await fetchNodeLogs()
return
}
const allowed = Boolean(me?.permissions?.includes('nodes.read'))
setCanRead(allowed)
if (!allowed) {
setEvents([])
return
}
const response = await fetchNodeLogs(
kindFilter === 'all' ? undefined : kindFilter,
)
if (!isCancelled) { if (!isCancelled) {
setEvents(response.events) setNodeEvents(response.events)
}
return
}
const response = await fetchAuthLogs()
if (!isCancelled) {
setAuthEvents(response.events)
} }
} catch (err) { } catch (err) {
if (!isCancelled) { if (!isCancelled) {
@@ -3002,29 +3132,184 @@ function LogsPanel() {
return () => { return () => {
isCancelled = true isCancelled = true
} }
}, [kindFilter]) }, [logsSection, canReadLogs, permissionsReady])
const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [ useEffect(() => {
setPage(1)
}, [
logsSection,
kindFilter,
nodeActionFilter,
authOutcomeFilter,
authCategoryFilter,
authActionFilter,
searchQuery,
datePreset,
customDateFrom,
customDateTo,
pageSize,
])
const kindFilters: ReadonlyArray<{ id: KindFilter; label: string }> = [
{ id: 'all', label: 'All' }, { id: 'all', label: 'All' },
{ id: 'container', label: 'Containers' }, { id: 'container', label: 'Containers' },
{ id: 'docker', label: 'Docker' }, { id: 'docker', label: 'Docker' },
{ id: 'vm', label: 'Virtual Machines' }, { id: 'vm', label: 'Virtual Machines' },
] ]
if (!isLoading && !canRead) { const nodeActionFilters: ReadonlyArray<{
return ( id: NodeActionFilter
<p className="config-hint"> label: string
You need the <code>nodes.read</code> permission to view node activity }> = [
logs. { id: 'all', label: 'All actions' },
</p> { id: 'create', label: 'create' },
{ id: 'update', label: 'update' },
{ id: 'delete', label: 'delete' },
{ id: 'test_ssh', label: 'test_ssh' },
]
const authOutcomeFilters: ReadonlyArray<{
id: AuthOutcomeFilter
label: string
}> = [
{ id: 'all', label: 'All outcomes' },
{ id: 'failure', label: 'Failures' },
{ id: 'success', label: 'Success' },
]
const authCategoryFilters: ReadonlyArray<{
id: AuthCategoryFilter
label: string
}> = [
{ id: 'all', label: 'All categories' },
{ id: 'auth', label: 'auth' },
{ id: 'user', label: 'user' },
{ id: 'group', label: 'group' },
{ id: 'self', label: 'self' },
]
const authActionFilters: ReadonlyArray<{
id: AuthActionQuickFilter
label: string
}> = [
{ id: 'all', label: 'All events' },
{ id: 'login_failures', label: 'Login failures' },
{ id: 'login', label: 'login' },
{ id: 'logout', label: 'logout' },
{ id: 'user_create', label: 'user_create' },
{ id: 'user_update', label: 'user_update' },
{ id: 'user_delete', label: 'user_delete' },
{ id: 'group_create', label: 'group_create' },
{ id: 'group_update', label: 'group_update' },
{ id: 'group_delete', label: 'group_delete' },
{ id: 'password_change', label: 'password_change' },
{ id: 'totp_enable', label: 'totp_enable' },
{ id: 'totp_disable', label: 'totp_disable' },
]
const datePresets: ReadonlyArray<{ id: DateRangePreset; label: string }> = [
{ id: 'all', label: 'All time' },
{ id: 'today', label: 'Today' },
{ id: '7d', label: 'Last 7 days' },
{ id: '30d', label: 'Last 30 days' },
{ id: 'custom', label: 'Custom' },
]
const { from: dateFrom, to: dateTo } = resolveDateRangeBounds(
datePreset,
customDateFrom,
customDateTo,
) )
const filteredNodeEvents = sortNodeEvents(
filterNodeEvents(nodeEvents, {
search: searchQuery,
kind: kindFilter,
action: nodeActionFilter,
dateFrom,
dateTo,
}),
nodeSortKey,
sortDirection,
)
const filteredAuthEvents = sortAuthEvents(
filterAuthEvents(authEvents, {
search: searchQuery,
outcome: authOutcomeFilter,
category: authCategoryFilter,
actionFilter: authActionFilter,
dateFrom,
dateTo,
}),
authSortKey,
sortDirection,
)
const activeEvents =
logsSection === 'nodes' ? filteredNodeEvents : filteredAuthEvents
const nodePagination = paginateItems(filteredNodeEvents, page, pageSize)
const authPagination = paginateItems(filteredAuthEvents, page, pageSize)
const pagination =
logsSection === 'nodes' ? nodePagination : authPagination
const displayNodeEvents =
logsSection === 'nodes' ? nodePagination.pageItems : []
const displayAuthEvents =
logsSection === 'auth' ? authPagination.pageItems : []
function handlePageSizeChange(nextSize: LogsPageSize) {
setPageSize(nextSize)
writeLogsPageSize(nextSize)
} }
function toggleNodeSort(key: NodeLogsSortKey) {
if (nodeSortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
return
}
setNodeSortKey(key)
setSortDirection(key === 'at' ? 'desc' : 'asc')
}
function toggleAuthSort(key: AuthLogsSortKey) {
if (authSortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
return
}
setAuthSortKey(key)
setSortDirection(key === 'at' ? 'desc' : 'asc')
}
function sortIndicator(active: boolean): string {
if (!active) {
return ''
}
return sortDirection === 'asc' ? ' ▲' : ' ▼'
}
function authRowClassName(event: AuthAuditEvent): string {
if (event.outcome === 'failure') {
return 'logs-row-failure'
}
if (event.category === 'self') {
return 'logs-row-self'
}
if (event.outcome === 'success') {
return 'logs-row-success'
}
return ''
}
function renderFilterChip<T extends string>(
filters: ReadonlyArray<{ id: T; label: string }>,
selected: T,
onSelect: (id: T) => void,
ariaLabel: string,
) {
return ( return (
<div className="logs-panel"> <div className="logs-filters" role="tablist" aria-label={ariaLabel}>
<div className="logs-filters" role="tablist" aria-label="Log kind filter">
{filters.map((filter) => { {filters.map((filter) => {
const isSelected = kindFilter === filter.id const isSelected = selected === filter.id
return ( return (
<button <button
key={filter.id} key={filter.id}
@@ -3034,33 +3319,235 @@ function LogsPanel() {
isSelected ? 'config-tab config-tab-active' : 'config-tab' isSelected ? 'config-tab config-tab-active' : 'config-tab'
} }
aria-selected={isSelected} aria-selected={isSelected}
onClick={() => setKindFilter(filter.id)} onClick={() => onSelect(filter.id)}
> >
{filter.label} {filter.label}
</button> </button>
) )
})} })}
</div> </div>
)
}
if (!permissionsReady) {
return (
<div className="logs-panel">
<p className="config-hint">Loading</p>
</div>
)
}
if (!canReadLogs) {
return (
<p className="config-hint">
You need the <code>logs.read</code> permission to view activity logs.
</p>
)
}
return (
<div className="logs-panel">
<div className="logs-filters" role="tablist" aria-label="Log section">
{(
[
{ id: 'nodes' as const, label: 'Nodes' },
{ id: 'auth' as const, label: 'Users & Auth' },
] as const
).map((section) => {
const isSelected = logsSection === section.id
return (
<button
key={section.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => {
setLogsSection(section.id)
setSortDirection('desc')
if (section.id === 'nodes') {
setNodeSortKey('at')
} else {
setAuthSortKey('at')
}
}}
>
{section.label}
</button>
)
})}
</div>
<div className="logs-toolbar">
<label className="logs-search-label" htmlFor="logs-search">
Search
<input
id="logs-search"
type="search"
className="logs-search-input"
placeholder="Node, user, group, event, detail…"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</label>
<label className="logs-page-size-label" htmlFor="logs-page-size">
Rows
<select
id="logs-page-size"
value={pageSize}
onChange={(event) =>
handlePageSizeChange(Number(event.target.value) as LogsPageSize)
}
>
{LOGS_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<button
type="button"
className={
filtersExpanded
? 'logs-filters-toggle logs-filters-toggle-open'
: 'logs-filters-toggle'
}
aria-expanded={filtersExpanded}
aria-controls="logs-filters-panel"
onClick={() => setFiltersExpanded((current) => !current)}
>
<FiltersChevronIcon expanded={filtersExpanded} />
{filtersExpanded ? 'Hide filters' : 'Filters…'}
</button>
</div>
{filtersExpanded ? (
<div
id="logs-filters-panel"
className="logs-filters-panel"
role="region"
aria-label="Log filters"
>
{renderFilterChip(
datePresets,
datePreset,
setDatePreset,
'Date range',
)}
{datePreset === 'custom' ? (
<div className="logs-date-custom">
<label htmlFor="logs-date-from">
From
<input
id="logs-date-from"
type="datetime-local"
value={customDateFrom}
onChange={(event) => setCustomDateFrom(event.target.value)}
/>
</label>
<label htmlFor="logs-date-to">
To
<input
id="logs-date-to"
type="datetime-local"
value={customDateTo}
onChange={(event) => setCustomDateTo(event.target.value)}
/>
</label>
</div>
) : null}
{logsSection === 'nodes'
? renderFilterChip(
kindFilters,
kindFilter,
setKindFilter,
'Log kind filter',
)
: null}
{logsSection === 'nodes'
? renderFilterChip(
nodeActionFilters,
nodeActionFilter,
setNodeActionFilter,
'Node action filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authOutcomeFilters,
authOutcomeFilter,
setAuthOutcomeFilter,
'Auth outcome filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authCategoryFilters,
authCategoryFilter,
setAuthCategoryFilter,
'Auth category filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authActionFilters,
authActionFilter,
setAuthActionFilter,
'Auth event filter',
)
: null}
</div>
) : null}
{isLoading ? <p className="config-hint">Loading</p> : null} {isLoading ? <p className="config-hint">Loading</p> : null}
{loadError ? <p className="config-error">{loadError}</p> : null} {loadError ? <p className="config-error">{loadError}</p> : null}
{!isLoading && !loadError && events.length === 0 ? (
<p className="config-hint">No node activity recorded yet.</p> {!isLoading && !loadError && activeEvents.length === 0 ? (
<p className="config-hint">
{logsSection === 'nodes'
? 'No node activity matches the current filters.'
: 'No user or auth activity matches the current filters.'}
</p>
) : null} ) : null}
{!isLoading && events.length > 0 ? (
{!isLoading && displayNodeEvents.length > 0 ? (
<div className="logs-table-scroll">
<table className="logs-table"> <table className="logs-table">
<thead> <thead>
<tr> <tr>
<th scope="col">When</th> {(
<th scope="col">Action</th> [
<th scope="col">Actor</th> { key: 'at' as const, label: 'When' },
<th scope="col">Node</th> { key: 'action' as const, label: 'Action' },
<th scope="col">Kind</th> { key: 'actor' as const, label: 'Actor' },
<th scope="col">Detail</th> { key: 'node' as const, label: 'Node' },
{ key: 'kind' as const, label: 'Kind' },
{ key: 'detail' as const, label: 'Detail' },
] as const
).map((column) => (
<th key={column.key} scope="col">
<button
type="button"
className="logs-sort-button"
onClick={() => toggleNodeSort(column.key)}
>
{column.label}
{sortIndicator(nodeSortKey === column.key)}
</button>
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{events.map((event) => ( {displayNodeEvents.map((event) => (
<tr key={event.id}> <tr key={event.id}>
<td>{formatUserTimestamp(event.at)}</td> <td>{formatUserTimestamp(event.at)}</td>
<td> <td>
@@ -3079,6 +3566,92 @@ function LogsPanel() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
) : null}
{!isLoading && displayAuthEvents.length > 0 ? (
<div className="logs-table-scroll">
<table className="logs-table">
<thead>
<tr>
{(
[
{ key: 'at' as const, label: 'When' },
{ key: 'action' as const, label: 'Action' },
{ key: 'outcome' as const, label: 'Outcome' },
{ key: 'category' as const, label: 'Category' },
{ key: 'actor' as const, label: 'Actor' },
{ key: 'target' as const, label: 'Target' },
{ key: 'detail' as const, label: 'Detail' },
] as const
).map((column) => (
<th key={column.key} scope="col">
<button
type="button"
className="logs-sort-button"
onClick={() => toggleAuthSort(column.key)}
>
{column.label}
{sortIndicator(authSortKey === column.key)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{displayAuthEvents.map((event) => (
<tr key={event.id} className={authRowClassName(event)}>
<td>{formatUserTimestamp(event.at)}</td>
<td>
<code>{event.action}</code>
</td>
<td>{event.outcome}</td>
<td>{event.category}</td>
<td>{event.actor}</td>
<td>{event.target || '—'}</td>
<td>{event.detail || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{!isLoading && activeEvents.length > 0 ? (
<div className="logs-pagination">
<span className="logs-pagination-label">
{pageRangeLabel(
activeEvents.length,
pagination.safePage,
pageSize,
)}
</span>
<div className="logs-pagination-controls">
<button
type="button"
className="config-tab"
disabled={pagination.safePage <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</button>
<span className="logs-pagination-page">
Page {pagination.safePage} of {pagination.totalPages}
</span>
<button
type="button"
className="config-tab"
disabled={pagination.safePage >= pagination.totalPages}
onClick={() =>
setPage((current) =>
Math.min(pagination.totalPages, current + 1),
)
}
>
Next
</button>
</div>
</div>
) : null} ) : null}
</div> </div>
) )
@@ -3187,6 +3760,11 @@ function App() {
const [backendVersion, setBackendVersion] = useState('…') const [backendVersion, setBackendVersion] = useState('…')
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' }) const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
const [canReadLogs, setCanReadLogs] = useState(false) const [canReadLogs, setCanReadLogs] = useState(false)
const [nodeSectionCounts, setNodeSectionCounts] =
useState<NodeSectionCounts | null>(null)
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
null,
)
const { themePreference, updateThemePreference } = useThemePreference() const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile' const profileLabel = profileDisplayName ?? 'Profile'
@@ -3226,7 +3804,7 @@ function App() {
} }
setProfileDisplayName(me.username) setProfileDisplayName(me.username)
setCanReadLogs(Boolean(me.permissions?.includes('nodes.read'))) setCanReadLogs(Boolean(me.permissions?.includes('logs.read')))
setBootState({ kind: 'ready', username: me.username }) setBootState({ kind: 'ready', username: me.username })
if ( if (
window.location.pathname === '/setup' || window.location.pathname === '/setup' ||
@@ -3249,6 +3827,58 @@ function App() {
} }
}, []) }, [])
useEffect(() => {
if (bootState.kind !== 'ready') {
return
}
let isCancelled = false
async function loadSidebarCounts(idleExempt: boolean) {
try {
const nodesResponse = await fetchNodes(undefined, fetch, {
idleExempt,
})
if (!isCancelled) {
setNodeSectionCounts(countNodesBySection(nodesResponse.nodes))
}
} catch {
if (!isCancelled) {
setNodeSectionCounts(null)
}
}
if (!canReadLogs) {
if (!isCancelled) {
setAuthFailuresToday(null)
}
return
}
try {
const authResponse = await fetchAuthLogs(fetch, { idleExempt })
if (!isCancelled) {
setAuthFailuresToday(countAuthFailuresToday(authResponse.events))
}
} catch {
if (!isCancelled) {
setAuthFailuresToday(null)
}
}
}
void loadSidebarCounts(false)
const pollIntervalId = window.setInterval(() => {
void loadSidebarCounts(true)
}, 30_000)
return () => {
isCancelled = true
window.clearInterval(pollIntervalId)
}
}, [bootState.kind, canReadLogs, activeSection])
useEffect(() => { useEffect(() => {
if (bootState.kind !== 'ready') { if (bootState.kind !== 'ready') {
return return
@@ -3308,6 +3938,22 @@ function App() {
navigateTo(pathFor(activeSection, 'overview', tab)) navigateTo(pathFor(activeSection, 'overview', tab))
} }
function clearSignedInState() {
setProfileDisplayName(null)
setCanReadLogs(false)
}
async function handleLogout() {
try {
await logout()
} catch {
// Still leave the signed-in UI even if the session is already gone.
}
clearSignedInState()
window.history.replaceState(null, '', '/login?reason=logout')
setBootState({ kind: 'login' })
}
if (bootState.kind === 'loading') { if (bootState.kind === 'loading') {
return ( return (
<div className="wizard-shell"> <div className="wizard-shell">
@@ -3321,8 +3967,8 @@ function App() {
<SetupWizard <SetupWizard
clientIp={bootState.clientIp} clientIp={bootState.clientIp}
onCompleted={() => { onCompleted={() => {
window.history.replaceState(null, '', '/login?from=setup')
setBootState({ kind: 'login' }) setBootState({ kind: 'login' })
navigateTo('/login')
}} }}
/> />
) )
@@ -3333,12 +3979,17 @@ function App() {
<LoginPage <LoginPage
onLoggedIn={(username) => { onLoggedIn={(username) => {
setProfileDisplayName(username) setProfileDisplayName(username)
void fetchMe().then((me) => { void (async () => {
setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read'))) try {
}) const me = await fetchMe()
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
} catch {
setCanReadLogs(false)
}
setBootState({ kind: 'ready', username }) setBootState({ kind: 'ready', username })
navigateTo('/') navigateTo('/')
setActiveSection('overview') setActiveSection('overview')
})()
}} }}
/> />
) )
@@ -3370,7 +4021,12 @@ function App() {
</button> </button>
))} ))}
<div className="nav-section-divider" aria-hidden="true" /> <div className="nav-section-divider" aria-hidden="true" />
{NAV_ITEMS.slice(1).map((item) => ( {NAV_ITEMS.slice(1).map((item) => {
const sectionCount =
isResourceSectionId(item.id) && nodeSectionCounts !== null
? nodeSectionCounts[item.id]
: null
return (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -3382,9 +4038,15 @@ function App() {
aria-current={activeSection === item.id ? 'page' : undefined} aria-current={activeSection === item.id ? 'page' : undefined}
onClick={() => goToSection(item.id)} onClick={() => goToSection(item.id)}
> >
{item.label} <span className="nav-item-label">{item.label}</span>
{sectionCount !== null ? (
<span className="nav-count-badge" aria-hidden="true">
{sectionCount}
</span>
) : null}
</button> </button>
))} )
})}
</nav> </nav>
{canReadLogs ? ( {canReadLogs ? (
@@ -3400,12 +4062,21 @@ function App() {
aria-current={activeSection === 'logs' ? 'page' : undefined} aria-current={activeSection === 'logs' ? 'page' : undefined}
onClick={() => goToSection('logs')} onClick={() => goToSection('logs')}
> >
Activity <span className="nav-item-label">Activity</span>
{authFailuresToday !== null && authFailuresToday > 0 ? (
<span
className="nav-count-badge nav-count-badge-alert"
aria-hidden="true"
>
{authFailuresToday}
</span>
) : null}
</button> </button>
</div> </div>
) : null} ) : null}
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="sidebar-footer-profile-row">
<button <button
type="button" type="button"
className={ className={
@@ -3419,6 +4090,17 @@ function App() {
<UserIcon /> <UserIcon />
{profileLabel} {profileLabel}
</button> </button>
<button
type="button"
className="nav-item logout-item"
aria-label="Log out"
onClick={() => {
void handleLogout()
}}
>
<LogoutIcon />
</button>
</div>
<button <button
type="button" type="button"
className={ className={
@@ -3451,8 +4133,7 @@ function App() {
) : activeSection === 'profile' ? ( ) : activeSection === 'profile' ? (
<ProfilePanel <ProfilePanel
onSignedOut={() => { onSignedOut={() => {
setProfileDisplayName(null) clearSignedInState()
setCanReadLogs(false)
setBootState({ kind: 'login' }) setBootState({ kind: 'login' })
navigateTo('/login') navigateTo('/login')
}} }}
+30
View File
@@ -21,6 +21,15 @@ describe('LoginPage', () => {
expect(window.location.search).toBe('') expect(window.location.search).toBe('')
}) })
it('shows a logged-out notice from the query string', () => {
window.history.replaceState(null, '', '/login?reason=logout')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
expect(window.location.search).toBe('')
})
it('does not show an idle notice without the query reason', () => { it('does not show an idle notice without the query reason', () => {
window.history.replaceState(null, '', '/login') window.history.replaceState(null, '', '/login')
@@ -28,4 +37,25 @@ describe('LoginPage', () => {
expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument()
}) })
it('shows the setup hint only when arriving from setup', () => {
window.history.replaceState(null, '', '/login?from=setup')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(
screen.getByText(/Use the administrator account created during setup/i),
).toBeInTheDocument()
expect(window.location.search).toBe('')
})
it('hides the setup hint on a normal login visit', () => {
window.history.replaceState(null, '', '/login')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(
screen.queryByText(/Use the administrator account created during setup/i),
).not.toBeInTheDocument()
})
}) })
+39 -9
View File
@@ -3,24 +3,49 @@ import { login } from './api/client'
const IDLE_SIGNED_OUT_MESSAGE = const IDLE_SIGNED_OUT_MESSAGE =
'You have been signed out because your idle time limit was reached.' 'You have been signed out because your idle time limit was reached.'
const LOGOUT_SIGNED_OUT_MESSAGE = 'You have been logged out.'
const SETUP_HINT_MESSAGE =
'Use the administrator account created during setup.'
type LoginPageProps = { type LoginPageProps = {
onLoggedIn: (username: string) => void onLoggedIn: (username: string) => void
} }
function readIdleSignedOutMessage(): string | null { function consumeLoginQueryFlag(flagName: string, expectedValue: string): boolean {
if (typeof window === 'undefined') {
return false
}
const params = new URLSearchParams(window.location.search)
if (params.get(flagName) !== expectedValue) {
return false
}
params.delete(flagName)
const nextSearch = params.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
window.history.replaceState(null, '', nextUrl)
return true
}
function readSignedOutMessage(): string | null {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null return null
} }
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
if (params.get('reason') !== 'idle') { const reason = params.get('reason')
let message: string | null = null
if (reason === 'idle') {
message = IDLE_SIGNED_OUT_MESSAGE
} else if (reason === 'logout') {
message = LOGOUT_SIGNED_OUT_MESSAGE
}
if (message === null) {
return null return null
} }
params.delete('reason') params.delete('reason')
const nextSearch = params.toString() const nextSearch = params.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}` const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
window.history.replaceState(null, '', nextUrl) window.history.replaceState(null, '', nextUrl)
return IDLE_SIGNED_OUT_MESSAGE return message
} }
export function LoginPage({ onLoggedIn }: LoginPageProps) { export function LoginPage({ onLoggedIn }: LoginPageProps) {
@@ -28,7 +53,12 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('') const [totpCode, setTotpCode] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [idleMessage] = useState<string | null>(() => readIdleSignedOutMessage()) const [signedOutMessage] = useState<string | null>(() =>
readSignedOutMessage(),
)
const [showSetupHint] = useState(() =>
consumeLoginQueryFlag('from', 'setup'),
)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(event: FormEvent) { async function handleSubmit(event: FormEvent) {
@@ -50,13 +80,13 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}> <form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
<p className="wizard-brand">Cluster Canvas</p> <p className="wizard-brand">Cluster Canvas</p>
<h1 className="wizard-title">Sign in</h1> <h1 className="wizard-title">Sign in</h1>
<p className="config-hint"> {showSetupHint ? (
Use the administrator account created during setup. <p className="config-hint">{SETUP_HINT_MESSAGE}</p>
</p> ) : null}
{idleMessage ? ( {signedOutMessage ? (
<p className="wizard-notice" role="status"> <p className="wizard-notice" role="status">
{idleMessage} {signedOutMessage}
</p> </p>
) : null} ) : null}
+79
View File
@@ -9,6 +9,7 @@ import {
fetchGroups, fetchGroups,
fetchHealth, fetchHealth,
fetchNetwork, fetchNetwork,
fetchAuthLogs,
fetchNodeLogs, fetchNodeLogs,
fetchSecurity, fetchSecurity,
fetchStatus, fetchStatus,
@@ -396,6 +397,51 @@ describe('fetchNodeLogs', () => {
}) })
}) })
describe('fetchAuthLogs', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed auth logs payload', async () => {
const events = [
{
id: 'a1',
at: '2026-01-01T00:00:00Z',
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
target: 'Admin',
},
]
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events }),
})
const payload = await fetchAuthLogs(fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/auth-logs',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.events).toEqual(events)
})
it('sends idle-exempt header when requested', async () => {
const { SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events: [] }),
})
await fetchAuthLogs(fetchMock, { idleExempt: true })
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
})
})
describe('fetchSecurity', () => { describe('fetchSecurity', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
@@ -565,6 +611,39 @@ describe('nodes API', () => {
) )
}) })
it('sends idle-exempt header when requested', async () => {
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await fetchNodes(undefined, fetchMock, { idleExempt: true })
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes',
expect.objectContaining({
credentials: 'include',
headers: expect.any(Headers),
}),
)
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
})
it('omits idle-exempt header by default', async () => {
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await fetchNodes(undefined, fetchMock)
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBeNull()
})
it('creates a node with generate options', async () => { it('creates a node with generate options', async () => {
const { createNode } = await import('./client') const { createNode } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
+41 -1
View File
@@ -156,6 +156,11 @@ async function readErrorMessage(response: Response): Promise<string> {
const SESSION_IDLE_ERROR = 'session_idle' const SESSION_IDLE_ERROR = 'session_idle'
const IDLE_LOGIN_PATH = '/login?reason=idle' const IDLE_LOGIN_PATH = '/login?reason=idle'
export const SESSION_IDLE_EXEMPT_HEADER = 'X-Session-Idle-Exempt'
export type ApiFetchOptions = {
idleExempt?: boolean
}
function shouldRedirectOnSessionIdle(path: string): boolean { function shouldRedirectOnSessionIdle(path: string): boolean {
if (path === '/api/v1/auth/login') { if (path === '/api/v1/auth/login') {
@@ -188,11 +193,15 @@ async function apiFetch(
path: string, path: string,
init: RequestInit = {}, init: RequestInit = {},
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<Response> { ): Promise<Response> {
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (init.body && !headers.has('Content-Type')) { if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
if (options.idleExempt) {
headers.set(SESSION_IDLE_EXEMPT_HEADER, '1')
}
const response = await fetchImpl(`${getApiBaseUrl()}${path}`, { const response = await fetchImpl(`${getApiBaseUrl()}${path}`, {
...init, ...init,
headers, headers,
@@ -680,9 +689,10 @@ export type CreateNodeRequest = {
export async function fetchNodes( export async function fetchNodes(
kind?: NodeKind, kind?: NodeKind,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<NodesResponse> { ): Promise<NodesResponse> {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : '' const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl) const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl, options)
if (!response.ok) { if (!response.ok) {
throw new Error(await readErrorMessage(response)) throw new Error(await readErrorMessage(response))
} }
@@ -793,6 +803,36 @@ export async function fetchNodeLogs(
return (await response.json()) as NodeLogsResponse return (await response.json()) as NodeLogsResponse
} }
export type AuthAuditOutcome = 'success' | 'failure'
export type AuthAuditCategory = 'auth' | 'user' | 'group' | 'self'
export type AuthAuditEvent = {
id: string
at: string
action: string
outcome: AuthAuditOutcome
category: AuthAuditCategory
actor: string
target?: string
detail?: string
}
export type AuthLogsResponse = {
events: ReadonlyArray<AuthAuditEvent>
}
export async function fetchAuthLogs(
fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<AuthLogsResponse> {
const response = await apiFetch('/api/v1/auth-logs', {}, fetchImpl, options)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as AuthLogsResponse
}
export type ActionKind = 'shell' | 'script' export type ActionKind = 'shell' | 'script'
export type ActionEnvVar = { export type ActionEnvVar = {
+6
View File
@@ -12,6 +12,9 @@
--ctp-subtext0: #6c6f85; --ctp-subtext0: #6c6f85;
--ctp-subtext1: #5c5f77; --ctp-subtext1: #5c5f77;
--ctp-mauve: #8839ef; --ctp-mauve: #8839ef;
--ctp-red: #d20f39;
--ctp-green: #40a02b;
--ctp-blue: #1e66f5;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base)); --ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base)); --ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
color-scheme: light; color-scheme: light;
@@ -30,6 +33,9 @@
--ctp-subtext0: #a6adc8; --ctp-subtext0: #a6adc8;
--ctp-subtext1: #bac2de; --ctp-subtext1: #bac2de;
--ctp-mauve: #cba6f7; --ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-green: #a6e3a1;
--ctp-blue: #89b4fa;
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base)); --ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base)); --ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
color-scheme: dark; color-scheme: dark;
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import type { AuthAuditEvent, NodeAuditEvent } from './api/client'
import {
eventMatchesSearch,
filterAuthEvents,
filterNodeEvents,
isWithinDateRange,
pageRangeLabel,
paginateItems,
resolveDateRangeBounds,
sortAuthEvents,
sortNodeEvents,
} from './logsFiltering'
const nodeEvents: NodeAuditEvent[] = [
{
id: '1',
at: '2026-07-18T12:00:00.000Z',
action: 'create',
actor: 'Admin',
node_id: 'n1',
node_name: 'alpha',
node_kind: 'container',
detail: 'created',
},
{
id: '2',
at: '2026-07-17T12:00:00.000Z',
action: 'delete',
actor: 'bob',
node_id: 'n2',
node_name: 'beta-vm',
node_kind: 'vm',
detail: 'removed',
},
{
id: '3',
at: '2026-07-10T12:00:00.000Z',
action: 'test_ssh',
actor: 'Admin',
node_id: 'n3',
node_name: 'gamma',
node_kind: 'docker',
},
]
const authEvents: AuthAuditEvent[] = [
{
id: 'a1',
at: '2026-07-18T15:00:00.000Z',
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'eve',
detail: 'invalid credentials',
},
{
id: 'a2',
at: '2026-07-18T14:00:00.000Z',
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
},
{
id: 'a3',
at: '2026-07-17T14:00:00.000Z',
action: 'user_create',
outcome: 'success',
category: 'user',
actor: 'Admin',
target: 'alice',
},
]
describe('eventMatchesSearch', () => {
it('matches any field case-insensitively', () => {
expect(eventMatchesSearch(['Admin', 'alpha'], 'ADM')).toBe(true)
expect(eventMatchesSearch(['Admin', 'alpha'], 'zzz')).toBe(false)
expect(eventMatchesSearch(['Admin'], ' ')).toBe(true)
})
})
describe('resolveDateRangeBounds', () => {
const now = new Date('2026-07-18T16:00:00.000Z')
it('returns null bounds for all', () => {
expect(resolveDateRangeBounds('all', '', '', now)).toEqual({
from: null,
to: null,
})
})
it('resolves today to local midnight', () => {
const { from, to } = resolveDateRangeBounds('today', '', '', now)
expect(to).toBeNull()
expect(from).not.toBeNull()
expect(from!.getHours()).toBe(0)
expect(from!.getMinutes()).toBe(0)
})
it('parses custom range', () => {
const { from, to } = resolveDateRangeBounds(
'custom',
'2026-07-01T00:00',
'2026-07-20T23:59',
now,
)
expect(from).not.toBeNull()
expect(to).not.toBeNull()
})
})
describe('isWithinDateRange', () => {
it('filters by from and to', () => {
const from = new Date('2026-07-17T00:00:00.000Z')
const to = new Date('2026-07-18T00:00:00.000Z')
expect(isWithinDateRange('2026-07-17T12:00:00.000Z', from, to)).toBe(true)
expect(isWithinDateRange('2026-07-16T12:00:00.000Z', from, to)).toBe(false)
expect(isWithinDateRange('2026-07-19T12:00:00.000Z', from, to)).toBe(false)
})
})
describe('filterNodeEvents', () => {
it('filters by kind, action, and search', () => {
const filtered = filterNodeEvents(nodeEvents, {
search: 'beta',
kind: 'vm',
action: 'delete',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.id).toBe('2')
})
})
describe('sortNodeEvents', () => {
it('sorts by when descending by default key', () => {
const sorted = sortNodeEvents(nodeEvents, 'at', 'desc')
expect(sorted.map((event) => event.id)).toEqual(['1', '2', '3'])
})
it('sorts by actor ascending', () => {
const sorted = sortNodeEvents(nodeEvents, 'actor', 'asc')
expect(sorted[0]?.actor).toBe('Admin')
expect(sorted[sorted.length - 1]?.actor).toBe('bob')
})
})
describe('filterAuthEvents', () => {
it('filters login failures', () => {
const filtered = filterAuthEvents(authEvents, {
search: '',
outcome: 'all',
category: 'all',
actionFilter: 'login_failures',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.id).toBe('a1')
})
it('filters by outcome and category', () => {
const filtered = filterAuthEvents(authEvents, {
search: 'alice',
outcome: 'success',
category: 'user',
actionFilter: 'all',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.target).toBe('alice')
})
})
describe('sortAuthEvents', () => {
it('sorts newest first by at', () => {
const sorted = sortAuthEvents(authEvents, 'at', 'desc')
expect(sorted.map((event) => event.id)).toEqual(['a1', 'a2', 'a3'])
})
})
describe('paginateItems', () => {
it('slices pages and clamps page number', () => {
const items = [1, 2, 3, 4, 5]
expect(paginateItems(items, 1, 2)).toEqual({
pageItems: [1, 2],
totalPages: 3,
safePage: 1,
})
expect(paginateItems(items, 99, 2).safePage).toBe(3)
expect(paginateItems(items, 99, 2).pageItems).toEqual([5])
})
})
describe('pageRangeLabel', () => {
it('formats the visible range', () => {
expect(pageRangeLabel(0, 1, 25)).toBe('Showing 0 of 0')
expect(pageRangeLabel(40, 2, 25)).toBe('Showing 2640 of 40')
})
})
+306
View File
@@ -0,0 +1,306 @@
import type {
AuthAuditEvent,
NodeAuditAction,
NodeAuditEvent,
NodeKind,
} from './api/client'
export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom'
export type SortDirection = 'asc' | 'desc'
export type NodeLogsSortKey =
| 'at'
| 'action'
| 'actor'
| 'node'
| 'kind'
| 'detail'
export type AuthLogsSortKey =
| 'at'
| 'action'
| 'actor'
| 'target'
| 'outcome'
| 'category'
| 'detail'
export type AuthOutcomeFilter = 'all' | 'failure' | 'success'
export type AuthCategoryFilter = 'all' | AuthAuditEvent['category']
export type AuthActionQuickFilter = 'all' | 'login_failures' | string
function normalizeSearchText(value: string): string {
return value.trim().toLowerCase()
}
export function eventMatchesSearch(
fields: ReadonlyArray<string | undefined | null>,
query: string,
): boolean {
const normalizedQuery = normalizeSearchText(query)
if (normalizedQuery === '') {
return true
}
return fields.some((field) =>
normalizeSearchText(field ?? '').includes(normalizedQuery),
)
}
export function startOfLocalDay(reference: Date = new Date()): Date {
return new Date(
reference.getFullYear(),
reference.getMonth(),
reference.getDate(),
)
}
export function resolveDateRangeBounds(
preset: DateRangePreset,
customFrom: string,
customTo: string,
now: Date = new Date(),
): { from: Date | null; to: Date | null } {
if (preset === 'all') {
return { from: null, to: null }
}
if (preset === 'today') {
return { from: startOfLocalDay(now), to: null }
}
if (preset === '7d') {
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
return { from, to: null }
}
if (preset === '30d') {
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
return { from, to: null }
}
const fromParsed = customFrom ? Date.parse(customFrom) : Number.NaN
const toParsed = customTo ? Date.parse(customTo) : Number.NaN
return {
from: Number.isNaN(fromParsed) ? null : new Date(fromParsed),
to: Number.isNaN(toParsed) ? null : new Date(toParsed),
}
}
export function isWithinDateRange(
at: string,
from: Date | null,
to: Date | null,
): boolean {
const timestamp = Date.parse(at)
if (Number.isNaN(timestamp)) {
return false
}
if (from !== null && timestamp < from.getTime()) {
return false
}
if (to !== null && timestamp > to.getTime()) {
return false
}
return true
}
function compareStrings(left: string, right: string): number {
return left.localeCompare(right, undefined, { sensitivity: 'base' })
}
function compareTimestamps(left: string, right: string): number {
const leftTime = Date.parse(left)
const rightTime = Date.parse(right)
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
return 0
}
if (Number.isNaN(leftTime)) {
return 1
}
if (Number.isNaN(rightTime)) {
return -1
}
return leftTime - rightTime
}
function applySortDirection(comparison: number, direction: SortDirection): number {
return direction === 'asc' ? comparison : -comparison
}
export function filterNodeEvents(
events: ReadonlyArray<NodeAuditEvent>,
options: {
search: string
kind: 'all' | NodeKind
action: 'all' | NodeAuditAction
dateFrom: Date | null
dateTo: Date | null
},
): NodeAuditEvent[] {
return events.filter((event) => {
if (options.kind !== 'all' && event.node_kind !== options.kind) {
return false
}
if (options.action !== 'all' && event.action !== options.action) {
return false
}
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
return false
}
return eventMatchesSearch(
[
event.at,
event.action,
event.actor,
event.node_name,
event.node_id,
event.node_kind,
event.detail,
],
options.search,
)
})
}
export function sortNodeEvents(
events: ReadonlyArray<NodeAuditEvent>,
sortKey: NodeLogsSortKey,
direction: SortDirection,
): NodeAuditEvent[] {
const sorted = [...events]
sorted.sort((left, right) => {
let comparison = 0
switch (sortKey) {
case 'at':
comparison = compareTimestamps(left.at, right.at)
break
case 'action':
comparison = compareStrings(left.action, right.action)
break
case 'actor':
comparison = compareStrings(left.actor, right.actor)
break
case 'node':
comparison = compareStrings(left.node_name, right.node_name)
break
case 'kind':
comparison = compareStrings(left.node_kind, right.node_kind)
break
case 'detail':
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
break
}
return applySortDirection(comparison, direction)
})
return sorted
}
export function filterAuthEvents(
events: ReadonlyArray<AuthAuditEvent>,
options: {
search: string
outcome: AuthOutcomeFilter
category: AuthCategoryFilter
actionFilter: AuthActionQuickFilter
dateFrom: Date | null
dateTo: Date | null
},
): AuthAuditEvent[] {
return events.filter((event) => {
if (options.outcome !== 'all' && event.outcome !== options.outcome) {
return false
}
if (options.category !== 'all' && event.category !== options.category) {
return false
}
if (options.actionFilter === 'login_failures') {
if (event.action !== 'login' || event.outcome !== 'failure') {
return false
}
} else if (
options.actionFilter !== 'all' &&
event.action !== options.actionFilter
) {
return false
}
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
return false
}
return eventMatchesSearch(
[
event.at,
event.action,
event.outcome,
event.category,
event.actor,
event.target,
event.detail,
],
options.search,
)
})
}
export function sortAuthEvents(
events: ReadonlyArray<AuthAuditEvent>,
sortKey: AuthLogsSortKey,
direction: SortDirection,
): AuthAuditEvent[] {
const sorted = [...events]
sorted.sort((left, right) => {
let comparison = 0
switch (sortKey) {
case 'at':
comparison = compareTimestamps(left.at, right.at)
break
case 'action':
comparison = compareStrings(left.action, right.action)
break
case 'actor':
comparison = compareStrings(left.actor, right.actor)
break
case 'target':
comparison = compareStrings(left.target ?? '', right.target ?? '')
break
case 'outcome':
comparison = compareStrings(left.outcome, right.outcome)
break
case 'category':
comparison = compareStrings(left.category, right.category)
break
case 'detail':
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
break
}
return applySortDirection(comparison, direction)
})
return sorted
}
export function paginateItems<T>(
items: ReadonlyArray<T>,
page: number,
pageSize: number,
): { pageItems: T[]; totalPages: number; safePage: number } {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize))
const safePage = Math.min(Math.max(1, page), totalPages)
const startIndex = (safePage - 1) * pageSize
return {
pageItems: items.slice(startIndex, startIndex + pageSize),
totalPages,
safePage,
}
}
export function pageRangeLabel(
totalCount: number,
page: number,
pageSize: number,
): string {
if (totalCount === 0) {
return 'Showing 0 of 0'
}
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, totalCount)
return `Showing ${start}${end} of ${totalCount}`
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LOGS_PAGE_SIZE,
isLogsPageSize,
LOGS_PAGE_SIZE_STORAGE_KEY,
readLogsPageSize,
writeLogsPageSize,
} from './logsPreferences'
describe('logsPreferences', () => {
it('defaults to 25', () => {
const storage = {
getItem: () => null,
setItem: () => undefined,
}
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
})
it('reads and writes a valid page size', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
}
writeLogsPageSize(100, storage)
expect(values.get(LOGS_PAGE_SIZE_STORAGE_KEY)).toBe('100')
expect(readLogsPageSize(storage)).toBe(100)
})
it('rejects invalid sizes', () => {
expect(isLogsPageSize(25)).toBe(true)
expect(isLogsPageSize(30)).toBe(false)
const storage = {
getItem: () => '9999',
setItem: () => undefined,
}
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
})
})
+36
View File
@@ -0,0 +1,36 @@
export const LOGS_PAGE_SIZE_STORAGE_KEY = 'clustercanvas.logs.pageSize'
export const LOGS_PAGE_SIZE_OPTIONS = [
25, 50, 100, 250, 500, 1000,
] as const
export type LogsPageSize = (typeof LOGS_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_LOGS_PAGE_SIZE: LogsPageSize = 25
const VALID_PAGE_SIZES: ReadonlySet<number> = new Set(LOGS_PAGE_SIZE_OPTIONS)
export function isLogsPageSize(value: number): value is LogsPageSize {
return VALID_PAGE_SIZES.has(value)
}
export function readLogsPageSize(
storage: Pick<Storage, 'getItem'> = localStorage,
): LogsPageSize {
const storedValue = storage.getItem(LOGS_PAGE_SIZE_STORAGE_KEY)
if (storedValue === null) {
return DEFAULT_LOGS_PAGE_SIZE
}
const parsed = Number.parseInt(storedValue, 10)
if (Number.isFinite(parsed) && isLogsPageSize(parsed)) {
return parsed
}
return DEFAULT_LOGS_PAGE_SIZE
}
export function writeLogsPageSize(
pageSize: LogsPageSize,
storage: Pick<Storage, 'setItem'> = localStorage,
): void {
storage.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize))
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { AuthAuditEvent, Node } from './api/client'
import {
countAuthFailuresToday,
countNodesBySection,
} from './sidebarCounts'
function makeNode(kind: Node['kind'], id: string): Pick<Node, 'kind' | 'id'> {
return { kind, id }
}
describe('countNodesBySection', () => {
it('returns zeros for an empty list', () => {
expect(countNodesBySection([])).toEqual({
containers: 0,
docker: 0,
vms: 0,
})
})
it('counts nodes by kind into section ids', () => {
expect(
countNodesBySection([
makeNode('container', 'c1'),
makeNode('container', 'c2'),
makeNode('docker', 'd1'),
makeNode('vm', 'v1'),
makeNode('vm', 'v2'),
makeNode('vm', 'v3'),
]),
).toEqual({
containers: 2,
docker: 1,
vms: 3,
})
})
})
describe('countAuthFailuresToday', () => {
const now = new Date(2026, 6, 18, 15, 30, 0)
function makeEvent(
at: string,
outcome: AuthAuditEvent['outcome'],
action = 'login',
): Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'> {
return { at, action, outcome }
}
it('counts only login failures within the local day', () => {
expect(
countAuthFailuresToday(
[
makeEvent('2026-07-18T08:00:00.000Z', 'failure'),
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
makeEvent('2026-07-18T12:00:00.000Z', 'failure'),
makeEvent('2026-07-16T12:00:00.000Z', 'failure'),
makeEvent('2026-07-18T14:00:00.000Z', 'failure', 'logout'),
makeEvent('2026-07-18T15:00:00.000Z', 'failure', 'user_create'),
],
now,
),
).toBe(2)
})
it('returns zero when there are no login failures today', () => {
expect(
countAuthFailuresToday(
[
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
makeEvent('2026-07-17T10:00:00.000Z', 'failure'),
makeEvent('2026-07-18T11:00:00.000Z', 'failure', 'reauth'),
],
now,
),
).toBe(0)
})
})
+43
View File
@@ -0,0 +1,43 @@
import type { AuthAuditEvent, Node, NodeKind } from './api/client'
import type { ResourceSectionId } from './navigation'
import { isWithinDateRange, startOfLocalDay } from './logsFiltering'
const KIND_TO_SECTION: Record<NodeKind, ResourceSectionId> = {
container: 'containers',
docker: 'docker',
vm: 'vms',
}
export type NodeSectionCounts = Record<ResourceSectionId, number>
export function countNodesBySection(
nodes: ReadonlyArray<Pick<Node, 'kind'>>,
): NodeSectionCounts {
const counts: NodeSectionCounts = {
containers: 0,
docker: 0,
vms: 0,
}
for (const node of nodes) {
counts[KIND_TO_SECTION[node.kind]] += 1
}
return counts
}
export function countAuthFailuresToday(
events: ReadonlyArray<Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'>>,
now: Date = new Date(),
): number {
const from = startOfLocalDay(now)
let failureCount = 0
for (const event of events) {
if (event.action !== 'login' || event.outcome !== 'failure') {
continue
}
if (!isWithinDateRange(event.at, from, null)) {
continue
}
failureCount += 1
}
return failureCount
}