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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 26–40 of 40')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user