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()
} }
+781 -100
View File
File diff suppressed because it is too large Load Diff
+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
}