Skip to content

Commit ca55d2b

Browse files
gcmsgclaude
andcommitted
feat(dashboard): Phase 19-22 — bulk actions, password strength, audit log, advanced dashboard
Phase 19 (Bulk Actions): - Backend bulk endpoints: POST /admin/agents/bulk, /reports/bulk, /users/bulk - SelectableTable component with checkbox selection and floating action bar - Migrate AgentsPage, UsersPage, ReportsPage to use SelectableTable - Frontend API functions and hooks for bulk operations Phase 20 (Profile & Auth): - PasswordStrength component with 4-segment visual bar and requirements checklist - Applied to RegisterForm, ProfilePage, ForgotPasswordPage - Auto-dismiss success messages after 3s on ProfilePage - AboutPage roadmap updated with Phase 4 and GitHub link Phase 21 (Admin Audit Log): - New adminaudit package (store/sqlite/postgres/service/factory) - Records admin actions: user.delete, user.role_change, agent.delete, agent.verify, agent.unverify, report.update, report.delete, category.create/update/delete - GET /admin/audit endpoint with filtering by admin, action, target, date - AuditLogPage with filters, pagination, and table - Sidebar nav item with ClipboardList icon Phase 22 (Advanced Dashboard): - Clickable stat cards navigating to respective admin pages - Recent Activity feed on Overview using audit events - Provider dashboard time range selector (7d/30d/All) - Backend since param support for provider dashboard All i18n keys added across 8 locale files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b631f8 commit ca55d2b

36 files changed

Lines changed: 1910 additions & 321 deletions

cmd/peerclawd/main.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"database/sql"
1515

1616
"github.com/peerclaw/peerclaw-core/agentcard"
17+
"github.com/peerclaw/peerclaw-server/internal/adminaudit"
1718
"github.com/peerclaw/peerclaw-server/internal/audit"
1819
"github.com/peerclaw/peerclaw-server/internal/bridge"
1920
"github.com/peerclaw/peerclaw-server/internal/bridge/a2a"
@@ -233,6 +234,18 @@ func main() {
233234
logger.Info("user ACL service initialized")
234235
}
235236

237+
// Initialize admin audit service.
238+
var adminAuditService *adminaudit.Service
239+
if sqlDB != nil {
240+
aaStore := adminaudit.NewStore(cfg.Database.Driver, sqlDB)
241+
if err := aaStore.Migrate(context.Background()); err != nil {
242+
logger.Error("failed to migrate admin audit tables", "error", err)
243+
os.Exit(1)
244+
}
245+
adminAuditService = adminaudit.NewService(aaStore, logger)
246+
logger.Info("admin audit service initialized")
247+
}
248+
236249
// Forward-declare sigHub so the notification emitter closure can reference it.
237250
var sigHub *signaling.Hub
238251

@@ -417,6 +430,9 @@ func main() {
417430
httpServer.SetNotification(notificationSvc)
418431
httpServer.SetNotificationHub(notifHub)
419432
}
433+
if adminAuditService != nil {
434+
httpServer.SetAdminAudit(adminAuditService)
435+
}
420436
if sigHub != nil {
421437
sigHub.SetAudit(auditLogger)
422438
sigHub.SetMetrics(otelMetrics)

internal/adminaudit/factory.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package adminaudit
2+
3+
import "database/sql"
4+
5+
// NewStore creates a Store based on the driver name.
6+
func NewStore(driver string, db *sql.DB) Store {
7+
switch driver {
8+
case "postgres":
9+
return NewPostgresStore(db)
10+
default:
11+
return NewSQLiteStore(db)
12+
}
13+
}

internal/adminaudit/postgres.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package adminaudit
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"strings"
8+
"time"
9+
)
10+
11+
// PostgresStore implements Store using PostgreSQL.
12+
type PostgresStore struct {
13+
db *sql.DB
14+
}
15+
16+
// NewPostgresStore creates a new PostgreSQL-backed admin audit store.
17+
func NewPostgresStore(db *sql.DB) *PostgresStore {
18+
return &PostgresStore{db: db}
19+
}
20+
21+
func (s *PostgresStore) Migrate(ctx context.Context) error {
22+
stmts := []string{
23+
`CREATE TABLE IF NOT EXISTS admin_audit_log (
24+
id TEXT PRIMARY KEY,
25+
admin_user_id TEXT NOT NULL,
26+
action TEXT NOT NULL,
27+
target_type TEXT NOT NULL DEFAULT '',
28+
target_id TEXT NOT NULL DEFAULT '',
29+
details TEXT DEFAULT '',
30+
ip_address TEXT DEFAULT '',
31+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
32+
)`,
33+
`CREATE INDEX IF NOT EXISTS idx_admin_audit_admin ON admin_audit_log(admin_user_id, created_at DESC)`,
34+
`CREATE INDEX IF NOT EXISTS idx_admin_audit_time ON admin_audit_log(created_at DESC)`,
35+
}
36+
for _, stmt := range stmts {
37+
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
38+
return fmt.Errorf("admin audit migrate: %w", err)
39+
}
40+
}
41+
return nil
42+
}
43+
44+
func (s *PostgresStore) Insert(ctx context.Context, event *AdminAuditEvent) error {
45+
_, err := s.db.ExecContext(ctx,
46+
`INSERT INTO admin_audit_log (id, admin_user_id, action, target_type, target_id, details, ip_address, created_at)
47+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
48+
event.ID, event.AdminUserID, event.Action, event.TargetType,
49+
event.TargetID, event.Details, event.IPAddress,
50+
event.CreatedAt.UTC(),
51+
)
52+
return err
53+
}
54+
55+
func (s *PostgresStore) List(ctx context.Context, adminUserID, action, targetType string, since time.Time, limit, offset int) ([]AdminAuditEvent, int, error) {
56+
if limit <= 0 {
57+
limit = 50
58+
}
59+
60+
var where []string
61+
var args []any
62+
paramIdx := 1
63+
64+
if adminUserID != "" {
65+
where = append(where, fmt.Sprintf("admin_user_id = $%d", paramIdx))
66+
args = append(args, adminUserID)
67+
paramIdx++
68+
}
69+
if action != "" {
70+
where = append(where, fmt.Sprintf("action = $%d", paramIdx))
71+
args = append(args, action)
72+
paramIdx++
73+
}
74+
if targetType != "" {
75+
where = append(where, fmt.Sprintf("target_type = $%d", paramIdx))
76+
args = append(args, targetType)
77+
paramIdx++
78+
}
79+
if !since.IsZero() {
80+
where = append(where, fmt.Sprintf("created_at >= $%d", paramIdx))
81+
args = append(args, since.UTC())
82+
paramIdx++
83+
}
84+
85+
whereClause := ""
86+
if len(where) > 0 {
87+
whereClause = "WHERE " + strings.Join(where, " AND ")
88+
}
89+
90+
// Count total.
91+
var total int
92+
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM admin_audit_log %s", whereClause)
93+
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
94+
return nil, 0, err
95+
}
96+
97+
// Fetch page.
98+
query := fmt.Sprintf(
99+
"SELECT id, admin_user_id, action, target_type, target_id, details, ip_address, created_at FROM admin_audit_log %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
100+
whereClause, paramIdx, paramIdx+1,
101+
)
102+
pageArgs := append(args, limit, offset)
103+
rows, err := s.db.QueryContext(ctx, query, pageArgs...)
104+
if err != nil {
105+
return nil, 0, err
106+
}
107+
defer rows.Close()
108+
109+
var events []AdminAuditEvent
110+
for rows.Next() {
111+
var e AdminAuditEvent
112+
if err := rows.Scan(&e.ID, &e.AdminUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
113+
return nil, 0, err
114+
}
115+
events = append(events, e)
116+
}
117+
return events, total, rows.Err()
118+
}
119+
120+
func (s *PostgresStore) Close() error {
121+
return nil // shared db, don't close
122+
}

internal/adminaudit/service.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package adminaudit
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
)
10+
11+
// Service implements admin audit business logic.
12+
type Service struct {
13+
store Store
14+
logger *slog.Logger
15+
}
16+
17+
// NewService creates a new admin audit service.
18+
func NewService(store Store, logger *slog.Logger) *Service {
19+
if logger == nil {
20+
logger = slog.Default()
21+
}
22+
return &Service{store: store, logger: logger}
23+
}
24+
25+
// Record saves an admin audit event.
26+
func (s *Service) Record(ctx context.Context, adminUserID, action, targetType, targetID, details, ipAddress string) {
27+
event := &AdminAuditEvent{
28+
ID: uuid.New().String(),
29+
AdminUserID: adminUserID,
30+
Action: action,
31+
TargetType: targetType,
32+
TargetID: targetID,
33+
Details: details,
34+
IPAddress: ipAddress,
35+
CreatedAt: time.Now().UTC(),
36+
}
37+
if err := s.store.Insert(ctx, event); err != nil {
38+
s.logger.Debug("failed to record admin audit event",
39+
"action", action,
40+
"admin_user_id", adminUserID,
41+
"error", err,
42+
)
43+
}
44+
}
45+
46+
// List returns audit events with optional filtering.
47+
func (s *Service) List(ctx context.Context, adminUserID, action, targetType string, since time.Time, limit, offset int) ([]AdminAuditEvent, int, error) {
48+
return s.store.List(ctx, adminUserID, action, targetType, since, limit, offset)
49+
}

internal/adminaudit/sqlite.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package adminaudit
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"strings"
8+
"time"
9+
)
10+
11+
// SQLiteStore implements Store using SQLite.
12+
type SQLiteStore struct {
13+
db *sql.DB
14+
}
15+
16+
// NewSQLiteStore creates a new SQLite-backed admin audit store.
17+
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
18+
return &SQLiteStore{db: db}
19+
}
20+
21+
func (s *SQLiteStore) Migrate(ctx context.Context) error {
22+
stmts := []string{
23+
`CREATE TABLE IF NOT EXISTS admin_audit_log (
24+
id TEXT PRIMARY KEY,
25+
admin_user_id TEXT NOT NULL,
26+
action TEXT NOT NULL,
27+
target_type TEXT NOT NULL DEFAULT '',
28+
target_id TEXT NOT NULL DEFAULT '',
29+
details TEXT DEFAULT '',
30+
ip_address TEXT DEFAULT '',
31+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
32+
)`,
33+
`CREATE INDEX IF NOT EXISTS idx_admin_audit_admin ON admin_audit_log(admin_user_id, created_at DESC)`,
34+
`CREATE INDEX IF NOT EXISTS idx_admin_audit_time ON admin_audit_log(created_at DESC)`,
35+
}
36+
for _, stmt := range stmts {
37+
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
38+
return fmt.Errorf("admin audit migrate: %w", err)
39+
}
40+
}
41+
return nil
42+
}
43+
44+
func (s *SQLiteStore) Insert(ctx context.Context, event *AdminAuditEvent) error {
45+
_, err := s.db.ExecContext(ctx,
46+
`INSERT INTO admin_audit_log (id, admin_user_id, action, target_type, target_id, details, ip_address, created_at)
47+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
48+
event.ID, event.AdminUserID, event.Action, event.TargetType,
49+
event.TargetID, event.Details, event.IPAddress,
50+
event.CreatedAt.UTC().Format(time.RFC3339),
51+
)
52+
return err
53+
}
54+
55+
func (s *SQLiteStore) List(ctx context.Context, adminUserID, action, targetType string, since time.Time, limit, offset int) ([]AdminAuditEvent, int, error) {
56+
if limit <= 0 {
57+
limit = 50
58+
}
59+
60+
var where []string
61+
var args []any
62+
63+
if adminUserID != "" {
64+
where = append(where, "admin_user_id = ?")
65+
args = append(args, adminUserID)
66+
}
67+
if action != "" {
68+
where = append(where, "action = ?")
69+
args = append(args, action)
70+
}
71+
if targetType != "" {
72+
where = append(where, "target_type = ?")
73+
args = append(args, targetType)
74+
}
75+
if !since.IsZero() {
76+
where = append(where, "created_at >= ?")
77+
args = append(args, since.UTC().Format(time.RFC3339))
78+
}
79+
80+
whereClause := ""
81+
if len(where) > 0 {
82+
whereClause = "WHERE " + strings.Join(where, " AND ")
83+
}
84+
85+
// Count total.
86+
var total int
87+
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM admin_audit_log %s", whereClause)
88+
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
89+
return nil, 0, err
90+
}
91+
92+
// Fetch page.
93+
query := fmt.Sprintf(
94+
"SELECT id, admin_user_id, action, target_type, target_id, details, ip_address, created_at FROM admin_audit_log %s ORDER BY created_at DESC LIMIT ? OFFSET ?",
95+
whereClause,
96+
)
97+
pageArgs := append(args, limit, offset)
98+
rows, err := s.db.QueryContext(ctx, query, pageArgs...)
99+
if err != nil {
100+
return nil, 0, err
101+
}
102+
defer rows.Close()
103+
104+
var events []AdminAuditEvent
105+
for rows.Next() {
106+
var e AdminAuditEvent
107+
var createdAt string
108+
if err := rows.Scan(&e.ID, &e.AdminUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.IPAddress, &createdAt); err != nil {
109+
return nil, 0, err
110+
}
111+
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
112+
events = append(events, e)
113+
}
114+
return events, total, rows.Err()
115+
}
116+
117+
func (s *SQLiteStore) Close() error {
118+
return nil // shared db, don't close
119+
}

internal/adminaudit/store.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package adminaudit
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// AdminAuditEvent represents an admin action log entry.
9+
type AdminAuditEvent struct {
10+
ID string `json:"id"`
11+
AdminUserID string `json:"admin_user_id"`
12+
Action string `json:"action"`
13+
TargetType string `json:"target_type"`
14+
TargetID string `json:"target_id"`
15+
Details string `json:"details,omitempty"`
16+
IPAddress string `json:"ip_address,omitempty"`
17+
CreatedAt time.Time `json:"created_at"`
18+
}
19+
20+
// Store defines the persistence interface for admin audit events.
21+
type Store interface {
22+
// Insert records a new audit event.
23+
Insert(ctx context.Context, event *AdminAuditEvent) error
24+
25+
// List returns audit events with optional filtering.
26+
List(ctx context.Context, adminUserID, action, targetType string, since time.Time, limit, offset int) ([]AdminAuditEvent, int, error)
27+
28+
// Migrate creates the required tables.
29+
Migrate(ctx context.Context) error
30+
31+
// Close releases resources.
32+
Close() error
33+
}

0 commit comments

Comments
 (0)