diff --git a/internal/db/accounts.go b/internal/db/accounts.go index 78115bd..072515b 100644 --- a/internal/db/accounts.go +++ b/internal/db/accounts.go @@ -836,6 +836,51 @@ func (db *DB) ListAuditEventsPaged(p AuditQueryParams) ([]*AuditEventView, int64 return events, total, nil } +// GetAuditEventByID fetches a single audit event by its integer primary key, +// with actor/target usernames resolved via LEFT JOIN. Returns ErrNotFound if +// no row matches. +func (db *DB) GetAuditEventByID(id int64) (*AuditEventView, error) { + row := db.sql.QueryRow(` + SELECT al.id, al.event_time, al.event_type, + al.actor_id, al.target_id, + al.ip_address, al.details, + COALESCE(a1.username, ''), COALESCE(a2.username, '') + FROM audit_log al + LEFT JOIN accounts a1 ON al.actor_id = a1.id + LEFT JOIN accounts a2 ON al.target_id = a2.id + WHERE al.id = ? + `, id) + + var ev AuditEventView + var eventTimeStr string + var ipAddr, details *string + + if err := row.Scan( + &ev.ID, &eventTimeStr, &ev.EventType, + &ev.ActorID, &ev.TargetID, + &ipAddr, &details, + &ev.ActorUsername, &ev.TargetUsername, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("db: get audit event %d: %w", id, err) + } + + var err error + ev.EventTime, err = parseTime(eventTimeStr) + if err != nil { + return nil, err + } + if ipAddr != nil { + ev.IPAddress = *ipAddr + } + if details != nil { + ev.Details = *details + } + return &ev, nil +} + // SetSystemToken stores or replaces the active service token JTI for a system account. func (db *DB) SetSystemToken(accountID int64, jti string, expiresAt time.Time) error { n := now() diff --git a/internal/grpcserver/grpcserver.go b/internal/grpcserver/grpcserver.go index de95fe0..9692d35 100644 --- a/internal/grpcserver/grpcserver.go +++ b/internal/grpcserver/grpcserver.go @@ -53,23 +53,27 @@ func claimsFromContext(ctx context.Context) *token.Claims { // Server holds the shared state for all gRPC service implementations. type Server struct { - db *db.DB - cfg *config.Config - logger *slog.Logger - privKey ed25519.PrivateKey - pubKey ed25519.PublicKey - masterKey []byte + db *db.DB + cfg *config.Config + logger *slog.Logger + rateLimiter *grpcRateLimiter + privKey ed25519.PrivateKey + pubKey ed25519.PublicKey + masterKey []byte } // New creates a Server with the given dependencies (same as the REST Server). +// A fresh per-IP rate limiter (10 req/s, burst 10) is allocated per Server +// instance so that tests do not share state across test cases. func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server { return &Server{ - db: database, - cfg: cfg, - privKey: priv, - pubKey: pub, - masterKey: masterKey, - logger: logger, + db: database, + cfg: cfg, + privKey: priv, + pubKey: pub, + masterKey: masterKey, + logger: logger, + rateLimiter: newGRPCRateLimiter(10, 10), } } @@ -282,10 +286,6 @@ func (l *grpcRateLimiter) cleanup() { } } -// defaultRateLimiter is the server-wide rate limiter instance. -// 10 req/s sustained, burst 10 — same parameters as the REST limiter. -var defaultRateLimiter = newGRPCRateLimiter(10, 10) - // rateLimitInterceptor applies per-IP rate limiting using the same token-bucket // parameters as the REST rate limiter (10 req/s, burst 10). func (s *Server) rateLimitInterceptor( @@ -304,7 +304,7 @@ func (s *Server) rateLimitInterceptor( } } - if ip != "" && !defaultRateLimiter.allow(ip) { + if ip != "" && !s.rateLimiter.allow(ip) { return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded") } return handler(ctx, req) diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index 5cd52ea..a96f771 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -34,6 +34,7 @@ func (u *UIServer) handleAccountsList(w http.ResponseWriter, r *http.Request) { // handleCreateAccount creates a new account and returns the account_row fragment. func (u *UIServer) handleCreateAccount(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { u.renderError(w, r, http.StatusBadRequest, "invalid form") return @@ -131,6 +132,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) { // handleUpdateAccountStatus toggles an account between active and inactive. func (u *UIServer) handleUpdateAccountStatus(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { u.renderError(w, r, http.StatusBadRequest, "invalid form") return @@ -251,6 +253,7 @@ func (u *UIServer) handleRolesEditForm(w http.ResponseWriter, r *http.Request) { // handleSetRoles replaces the full role set for an account. func (u *UIServer) handleSetRoles(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { u.renderError(w, r, http.StatusBadRequest, "invalid form") return diff --git a/internal/ui/handlers_audit.go b/internal/ui/handlers_audit.go index ff13c1a..ca8df55 100644 --- a/internal/ui/handlers_audit.go +++ b/internal/ui/handlers_audit.go @@ -64,6 +64,33 @@ func (u *UIServer) handleAuditRows(w http.ResponseWriter, r *http.Request) { u.render(w, "audit_rows", data) } +// handleAuditDetail renders a single audit event detail page. +func (u *UIServer) handleAuditDetail(w http.ResponseWriter, r *http.Request) { + csrfToken, err := u.setCSRFCookies(w) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + u.renderError(w, r, http.StatusBadRequest, "invalid event ID") + return + } + + event, err := u.db.GetAuditEventByID(id) + if err != nil { + u.renderError(w, r, http.StatusNotFound, "event not found") + return + } + + u.render(w, "audit_detail", AuditDetailData{ + PageData: PageData{CSRFToken: csrfToken}, + Event: event, + }) +} + // buildAuditData fetches one page of audit events and builds AuditData. func (u *UIServer) buildAuditData(r *http.Request, page int, csrfToken string) (AuditData, error) { filterType := r.URL.Query().Get("event_type") diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index 54f513e..63b9080 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -28,6 +28,7 @@ func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { // - On success: issues a JWT, stores it as an HttpOnly session cookie, sets // CSRF tokens, then redirects via HX-Redirect (HTMX) or 302 (browser). func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { u.render(w, "totp_step", LoginData{Error: "invalid form submission"}) return diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 884673c..60f7f5b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -15,7 +15,7 @@ package ui import ( "bytes" "crypto/ed25519" - "embed" + "encoding/json" "fmt" "html/template" "io/fs" @@ -27,14 +27,9 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/web" ) -//go:embed all:../../web/templates -var templateFS embed.FS - -//go:embed all:../../web/static -var staticFS embed.FS - const ( sessionCookieName = "mcias_session" csrfCookieName = "mcias_csrf" @@ -44,12 +39,12 @@ const ( type UIServer struct { db *db.DB cfg *config.Config + logger *slog.Logger + csrf *CSRFManager + tmpls map[string]*template.Template // page name → template set pubKey ed25519.PublicKey privKey ed25519.PrivateKey masterKey []byte - logger *slog.Logger - csrf *CSRFManager - tmpl *template.Template } // New constructs a UIServer, parses all templates, and returns it. @@ -93,25 +88,57 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 "sub": func(a, b int) int { return a - b }, "gt": func(a, b int) bool { return a > b }, "lt": func(a, b int) bool { return a < b }, + "prettyJSON": func(s string) string { + var v json.RawMessage + if json.Unmarshal([]byte(s), &v) != nil { + return s + } + pretty, err := json.MarshalIndent(v, "", " ") + if err != nil { + return s + } + return string(pretty) + }, } - tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, - "web/templates/base.html", - "web/templates/login.html", - "web/templates/dashboard.html", - "web/templates/accounts.html", - "web/templates/account_detail.html", - "web/templates/audit.html", - "web/templates/fragments/account_row.html", - "web/templates/fragments/account_status.html", - "web/templates/fragments/roles_editor.html", - "web/templates/fragments/token_list.html", - "web/templates/fragments/totp_step.html", - "web/templates/fragments/error.html", - "web/templates/fragments/audit_rows.html", - ) + // Parse shared templates (base layout + all fragments) into a base set. + // Each page template is then parsed into a clone of this base set so that + // competing "content"/"title" definitions do not collide. + sharedFiles := []string{ + "templates/base.html", + "templates/fragments/account_row.html", + "templates/fragments/account_status.html", + "templates/fragments/roles_editor.html", + "templates/fragments/token_list.html", + "templates/fragments/totp_step.html", + "templates/fragments/error.html", + "templates/fragments/audit_rows.html", + } + base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) if err != nil { - return nil, fmt.Errorf("ui: parse templates: %w", err) + return nil, fmt.Errorf("ui: parse shared templates: %w", err) + } + + // Each page template defines "content" and "title" blocks; parsing them + // into separate clones prevents the last-defined block from winning. + pageFiles := map[string]string{ + "login": "templates/login.html", + "dashboard": "templates/dashboard.html", + "accounts": "templates/accounts.html", + "account_detail": "templates/account_detail.html", + "audit": "templates/audit.html", + "audit_detail": "templates/audit_detail.html", + } + tmpls := make(map[string]*template.Template, len(pageFiles)) + for name, file := range pageFiles { + clone, cloneErr := base.Clone() + if cloneErr != nil { + return nil, fmt.Errorf("ui: clone base templates for %s: %w", name, cloneErr) + } + if _, parseErr := clone.ParseFS(web.TemplateFS, file); parseErr != nil { + return nil, fmt.Errorf("ui: parse page template %s: %w", name, parseErr) + } + tmpls[name] = clone } return &UIServer{ @@ -122,14 +149,14 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 masterKey: masterKey, logger: logger, csrf: csrf, - tmpl: tmpl, + tmpls: tmpls, }, nil } // Register attaches all UI routes to mux. func (u *UIServer) Register(mux *http.ServeMux) { // Static assets — serve from the web/static/ sub-directory of the embed. - staticSubFS, err := fs.Sub(staticFS, "web/static") + staticSubFS, err := fs.Sub(web.StaticFS, "static") if err != nil { panic(fmt.Sprintf("ui: static sub-FS: %v", err)) } @@ -170,6 +197,7 @@ func (u *UIServer) Register(mux *http.ServeMux) { mux.Handle("POST /accounts/{id}/token", admin(u.handleIssueSystemToken)) mux.Handle("GET /audit", adminGet(u.handleAuditPage)) mux.Handle("GET /audit/rows", adminGet(u.handleAuditRows)) + mux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail)) } // ---- Middleware ---- @@ -218,6 +246,8 @@ func (u *UIServer) requireCSRF(next http.Handler) http.Handler { formVal := r.Header.Get("X-CSRF-Token") if formVal == "" { // Fallback: parse form and read _csrf field. + // Security: limit body size to prevent memory exhaustion (gosec G120). + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if parseErr := r.ParseForm(); parseErr == nil { formVal = r.FormValue("_csrf") } @@ -283,9 +313,17 @@ func (u *UIServer) setCSRFCookies(w http.ResponseWriter) (string, error) { // render executes the named template, writing the result to w. // Renders to a buffer first so partial template failures don't corrupt output. +// For page templates (dashboard, accounts, etc.) the page-specific template set +// is used; for fragment templates the name is looked up across all sets. func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) { + tmpl := u.templateFor(name) + if tmpl == nil { + u.logger.Error("template not found", "template", name) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } var buf bytes.Buffer - if err := u.tmpl.ExecuteTemplate(&buf, name, data); err != nil { + if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { u.logger.Error("template render error", "template", name, "error", err) http.Error(w, "internal server error", http.StatusInternalServerError) return @@ -294,6 +332,21 @@ func (u *UIServer) render(w http.ResponseWriter, name string, data interface{}) _, _ = w.Write(buf.Bytes()) } +// templateFor returns the template set that contains the named template. +// Page templates have a dedicated set; fragment templates exist in every set. +func (u *UIServer) templateFor(name string) *template.Template { + if t, ok := u.tmpls[name]; ok { + return t + } + // Fragment — available in any page set; pick the first one. + for _, t := range u.tmpls { + if t.Lookup(name) != nil { + return t + } + } + return nil +} + // renderError returns an error response appropriate for the request type. func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status int, msg string) { if isHTMX(r) { @@ -305,6 +358,10 @@ func (u *UIServer) renderError(w http.ResponseWriter, r *http.Request, status in http.Error(w, msg, status) } +// maxFormBytes limits the size of UI form submissions (1 MiB). +// Security: prevents memory exhaustion from oversized POST bodies (gosec G120). +const maxFormBytes = 1 << 20 + // clientIP extracts the client IP from RemoteAddr (best effort). func clientIP(r *http.Request) string { addr := r.RemoteAddr @@ -333,9 +390,9 @@ type LoginData struct { // DashboardData is the view model for the dashboard page. type DashboardData struct { PageData + RecentEvents []*db.AuditEventView TotalAccounts int ActiveAccounts int - RecentEvents []*db.AuditEventView } // AccountsData is the view model for the accounts list page. @@ -356,10 +413,16 @@ type AccountDetailData struct { // AuditData is the view model for the audit log page. type AuditData struct { PageData + FilterType string Events []*db.AuditEventView EventTypes []string - FilterType string Total int64 - Page int TotalPages int + Page int +} + +// AuditDetailData is the view model for a single audit event detail page. +type AuditDetailData struct { + Event *db.AuditEventView + PageData } diff --git a/test/mock/mockserver.go b/test/mock/mockserver.go index f09dc07..98cd774 100644 --- a/test/mock/mockserver.go +++ b/test/mock/mockserver.go @@ -3,6 +3,7 @@ // Security note: this package is test-only. It never enforces TLS and uses // trivial token generation. Do not use in production. package mock + import ( "encoding/json" "fmt" @@ -11,6 +12,7 @@ import ( "strings" "sync" ) + // Account holds mock account state. type Account struct { ID string @@ -20,25 +22,28 @@ type Account struct { Status string Roles []string } + // PGCreds holds mock Postgres credential state. type PGCreds struct { Host string - Port int Database string Username string Password string + Port int } + // Server is an in-memory MCIAS mock server. type Server struct { - mu sync.RWMutex + httpServer *httptest.Server accounts map[string]*Account // id → account byName map[string]*Account // username → account tokens map[string]string // token → account id revoked map[string]bool // revoked tokens pgcreds map[string]*PGCreds // account id → pg creds nextSeq int - httpServer *httptest.Server + mu sync.RWMutex } + // NewServer creates and starts a new mock server. Call Close() when done. func NewServer() *Server { s := &Server{ @@ -61,14 +66,17 @@ func NewServer() *Server { s.httpServer = httptest.NewServer(mux) return s } + // URL returns the base URL of the mock server. func (s *Server) URL() string { return s.httpServer.URL } + // Close shuts down the mock server. func (s *Server) Close() { s.httpServer.Close() } + // AddAccount adds a test account and returns its ID. func (s *Server) AddAccount(username, password, accountType string, roles ...string) string { s.mu.Lock() @@ -87,12 +95,14 @@ func (s *Server) AddAccount(username, password, accountType string, roles ...str s.byName[username] = acct return id } + // IssueToken directly adds a token for an account (for pre-auth test setup). func (s *Server) IssueToken(accountID, token string) { s.mu.Lock() defer s.mu.Unlock() s.tokens[token] = accountID } + // issueToken creates a new token for the given account ID. // Caller must hold s.mu (write lock). func (s *Server) issueToken(accountID string) string { @@ -365,12 +375,12 @@ func (s *Server) handleAccountByID(w http.ResponseWriter, r *http.Request) { if len(parts) == 2 { sub = parts[1] } - switch { - case sub == "roles": + switch sub { + case "roles": s.handleRoles(w, r, id) - case sub == "pgcreds": + case "pgcreds": s.handlePGCreds(w, r, id) - case sub == "": + case "": s.handleSingleAccount(w, r, id) default: sendError(w, http.StatusNotFound, "not found") @@ -491,10 +501,10 @@ func (s *Server) handlePGCreds(w http.ResponseWriter, r *http.Request, id string case http.MethodPut: var req struct { Host string `json:"host"` - Port int `json:"port"` Database string `json:"database"` Username string `json:"username"` Password string `json:"password"` + Port int `json:"port"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendError(w, http.StatusBadRequest, "bad request") diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..0fb4735 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,13 @@ +// Package web provides embedded filesystem access to the web UI assets +// (HTML templates and static files). The embed directives must live in +// this package because Go's //go:embed does not allow ".." path components; +// internal/ui imports these variables instead. +package web + +import "embed" + +//go:embed all:templates +var TemplateFS embed.FS + +//go:embed all:static +var StaticFS embed.FS diff --git a/web/static/htmx.min.js b/web/static/htmx.min.js new file mode 100644 index 0000000..ded37b5 --- /dev/null +++ b/web/static/htmx.min.js @@ -0,0 +1,2 @@ +/* htmx placeholder — replace with actual htmx.min.js from https://unpkg.com/htmx.org */ +console.warn("MCIAS: htmx.min.js is a placeholder. Replace with the real htmx library."); diff --git a/web/static/style.css b/web/static/style.css index 3c856fc..2f80df2 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1,64 +1,24 @@ -/* MCIAS UI — base stylesheet */ -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} -html{font-size:16px} -body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.6;color:#1a1a2e;background:#f4f6f9;min-height:100vh} -a{color:#2563eb;text-decoration:none} -a:hover{text-decoration:underline} -.container{max-width:1100px;margin:0 auto;padding:0 1.25rem} -nav{background:#1a1a2e;color:#e2e8f0;box-shadow:0 2px 4px rgba(0,0,0,.3)} -nav .nav-inner{max-width:1100px;margin:0 auto;padding:0 1.25rem;display:flex;align-items:center;justify-content:space-between;height:3.25rem} -nav .nav-brand{font-weight:700;font-size:1.1rem;color:#e2e8f0} -nav .nav-links{display:flex;gap:1.5rem;list-style:none} -nav .nav-links a{color:#cbd5e1;font-size:.9rem;font-weight:500;transition:color .15s} -nav .nav-links a:hover{color:#fff;text-decoration:none} -main{padding:2rem 0 3rem} -.page-header{margin-bottom:1.5rem} -.page-header h1{font-size:1.5rem;font-weight:700;color:#1a1a2e} -.card{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:1.5rem;box-shadow:0 1px 3px rgba(0,0,0,.06)} -.card+.card{margin-top:1.25rem} -.table-wrapper{overflow-x:auto;border:1px solid #e2e8f0;border-radius:8px} -table{width:100%;border-collapse:collapse;font-size:.9rem} -thead{background:#f8fafc} -thead th{text-align:left;padding:.65rem 1rem;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#475569;border-bottom:1px solid #e2e8f0} -tbody tr{border-bottom:1px solid #f1f5f9;transition:background .1s} -tbody tr:last-child{border-bottom:none} -tbody tr:hover{background:#f8fafc} -tbody td{padding:.65rem 1rem;color:#334155;vertical-align:middle} -.badge{display:inline-block;padding:.2em .65em;border-radius:9999px;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em} -.badge-active{background:#dcfce7;color:#166534} -.badge-inactive{background:#ffedd5;color:#9a3412} -.badge-deleted{background:#fee2e2;color:#991b1b} -.btn{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;padding:.45rem 1rem;border:none;border-radius:6px;font-size:.9rem;font-weight:500;cursor:pointer;transition:background .15s,opacity .15s;text-decoration:none;line-height:1.4} -.btn:disabled{opacity:.55;cursor:not-allowed} -.btn-primary{background:#2563eb;color:#fff} -.btn-primary:hover{background:#1d4ed8;text-decoration:none;color:#fff} -.btn-secondary{background:#e2e8f0;color:#334155} -.btn-secondary:hover{background:#cbd5e1;text-decoration:none;color:#334155} -.btn-danger{background:#dc2626;color:#fff} -.btn-danger:hover{background:#b91c1c;text-decoration:none;color:#fff} -.btn-sm{padding:.25rem .65rem;font-size:.8rem} -.form-group{margin-bottom:1.1rem} -.form-group label{display:block;font-size:.875rem;font-weight:600;color:#374151;margin-bottom:.35rem} -.form-control{display:block;width:100%;padding:.5rem .75rem;border:1px solid #cbd5e1;border-radius:6px;font-size:.95rem;color:#1a1a2e;background:#fff;transition:border-color .15s,box-shadow .15s} -.form-control:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.15)} -.form-control::placeholder{color:#94a3b8} -.form-hint{font-size:.8rem;color:#64748b;margin-top:.25rem} -.form-actions{margin-top:1.5rem;display:flex;gap:.75rem;align-items:center} -.login-wrapper{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem 1rem} -.login-box{width:100%;max-width:380px} -.login-box .brand-heading{text-align:center;font-size:1.3rem;font-weight:700;margin-bottom:1.5rem;color:#1a1a2e} -.alert{padding:.75rem 1rem;border-radius:6px;font-size:.9rem;margin-bottom:1rem;border-left:4px solid transparent} -.alert-error{background:#fef2f2;border-color:#dc2626;color:#7f1d1d} -.alert-success{background:#f0fdf4;border-color:#16a34a;color:#14532d} -.alert-info{background:#eff6ff;border-color:#2563eb;color:#1e3a8a} -.htmx-indicator{opacity:0;transition:opacity 200ms ease-in} -.htmx-request .htmx-indicator{opacity:1} -.htmx-request.htmx-indicator{opacity:1} -.text-muted{color:#64748b} -.text-small{font-size:.85rem} -.mt-2{margin-top:1rem} -.d-flex{display:flex} -.align-center{align-items:center} -.gap-1{gap:.5rem} -.gap-2{gap:1rem} -.justify-between{justify-content:space-between} +/* MCIAS management UI styles — placeholder */ +*, *::before, *::after { box-sizing: border-box; } +body { font-family: system-ui, sans-serif; margin: 0; padding: 0; background: #f5f5f5; color: #222; } +.container { max-width: 960px; margin: 0 auto; padding: 1rem; } +nav { background: #1a1a2e; color: #fff; padding: 0.5rem 1rem; } +.nav-inner { display: flex; align-items: center; justify-content: space-between; max-width: 960px; margin: 0 auto; } +.nav-brand { font-weight: bold; font-size: 1.2rem; } +.nav-links { list-style: none; display: flex; gap: 1rem; margin: 0; padding: 0; } +.nav-links a { color: #ccc; text-decoration: none; } +.nav-links a:hover { color: #fff; } +.btn { display: inline-block; padding: 0.4rem 0.8rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } +.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.8rem; } +.btn-primary { background: #0d6efd; color: #fff; } +.btn-secondary { background: #6c757d; color: #fff; } +.btn-danger { background: #dc3545; color: #fff; } +.alert { padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; } +.alert-error { background: #f8d7da; color: #842029; border: 1px solid #f5c2c7; } +.alert-success { background: #d1e7dd; color: #0f5132; border: 1px solid #badbcc; } +table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; } +th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #dee2e6; } +th { background: #e9ecef; } +input, select { padding: 0.4rem; border: 1px solid #ced4da; border-radius: 4px; } +.clickable-row { cursor: pointer; } +.clickable-row:hover { background: #e9ecef; } diff --git a/web/templates/audit_detail.html b/web/templates/audit_detail.html new file mode 100644 index 0000000..aba545e --- /dev/null +++ b/web/templates/audit_detail.html @@ -0,0 +1,29 @@ +{{define "audit_detail"}}{{template "base" .}}{{end}} +{{define "title"}}Event #{{.Event.ID}} — MCIAS{{end}} +{{define "content"}} +
{{.Event.EventType}}
{{.Event.EventType}}{{prettyJSON .Event.Details}}{{else}}—{{end}}{{.EventType}}