diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index c9dbbdb..75e9af5 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -171,17 +171,65 @@ func addRoutes( // Admin routes adminRoutes := []hws.Route{ + // Full page routes (for direct navigation and refreshes) { - // TODO: on page load, redirect to /admin/users Path: "/admin", Method: hws.MethodGET, Handler: perms.RequireAdmin(s)(handlers.AdminDashboard(s, conn)), }, + { + Path: "/admin/users", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminUsersPage(s, conn)), + }, + { + Path: "/admin/roles", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminRolesPage(s, conn)), + }, + { + Path: "/admin/permissions", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminPermissionsPage(s, conn)), + }, + { + Path: "/admin/audit", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsPage(s, conn)), + }, + // HTMX content fragment routes (for section swapping) { Path: "/admin/users", Method: hws.MethodPOST, Handler: perms.RequireAdmin(s)(handlers.AdminUsersList(s, conn)), }, + { + Path: "/admin/roles", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminRolesList(s, conn)), + }, + { + Path: "/admin/permissions", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminPermissionsList(s, conn)), + }, + { + Path: "/admin/audit", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsList(s, conn)), + }, + // Audit log filtering (returns only results table, no URL push) + { + Path: "/admin/audit/filter", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsFilter(s, conn)), + }, + // Audit log detail modal + { + Path: "/admin/audit/{id}", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogDetail(s, conn)), + }, } routes := append(pageroutes, htmxRoutes...) diff --git a/internal/db/auditlog.go b/internal/db/auditlog.go index 564798f..4c11469 100644 --- a/internal/db/auditlog.go +++ b/internal/db/auditlog.go @@ -41,29 +41,48 @@ func CreateAuditLog(ctx context.Context, tx bun.Tx, log *AuditLog) error { type AuditLogFilter struct { *ListFilter + customWhere []whereClause +} + +type whereClause struct { + query string + args []any } func NewAuditLogFilter() *AuditLogFilter { - return &AuditLogFilter{NewListFilter()} + return &AuditLogFilter{ + ListFilter: NewListFilter(), + customWhere: []whereClause{}, + } } func (a *AuditLogFilter) UserID(id int) *AuditLogFilter { - a.Add("al.user_id", id) + a.Add("al.user_id", "=", id) return a } func (a *AuditLogFilter) Action(action string) *AuditLogFilter { - a.Add("al.action", action) + a.Add("al.action", "=", action) return a } func (a *AuditLogFilter) ResourceType(resourceType string) *AuditLogFilter { - a.Add("al.resource_type", resourceType) + a.Add("al.resource_type", "=", resourceType) return a } func (a *AuditLogFilter) Result(result string) *AuditLogFilter { - a.Add("al.result", result) + a.Add("al.result", "=", result) + return a +} + +func (a *AuditLogFilter) DateRange(start, end int64) *AuditLogFilter { + if start > 0 { + a.Add("al.created_at", ">=", start) + } + if end > 0 { + a.Add("al.created_at", "<=", end) + } return a } @@ -75,10 +94,17 @@ func GetAuditLogs(ctx context.Context, tx bun.Tx, pageOpts *PageOpts, filters *A Order: bun.OrderDesc, OrderBy: "created_at", } - return GetList[AuditLog](tx). + + lg := GetList[AuditLog](tx). Relation("User"). - Filter(filters.filters...). - GetPaged(ctx, pageOpts, defaultPageOpts) + Filter(filters.filters...) + + // Apply custom where clauses (e.g., date range) + for _, clause := range filters.customWhere { + lg = lg.Where(clause.query, clause.args...) + } + + return lg.GetPaged(ctx, pageOpts, defaultPageOpts) } // GetAuditLogsByUser retrieves audit logs for a specific user @@ -101,6 +127,57 @@ func GetAuditLogsByAction(ctx context.Context, tx bun.Tx, action string, pageOpt return GetAuditLogs(ctx, tx, pageOpts, filters) } +// GetAuditLogByID retrieves a single audit log by ID +func GetAuditLogByID(ctx context.Context, tx bun.Tx, id int) (*AuditLog, error) { + if id <= 0 { + return nil, errors.New("id must be positive") + } + + log := new(AuditLog) + err := tx.NewSelect(). + Model(log). + Relation("User"). + Where("al.id = ?", id). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.NewSelect") + } + return log, nil +} + +// GetUniqueActions retrieves a list of all unique actions in the audit log +func GetUniqueActions(ctx context.Context, tx bun.Tx) ([]string, error) { + var actions []string + err := tx.NewSelect(). + Model((*AuditLog)(nil)). + Column("action"). + Distinct(). + Order("action ASC"). + Scan(ctx, &actions) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return actions, nil +} + +// GetUniqueResourceTypes retrieves a list of all unique resource types in the audit log +func GetUniqueResourceTypes(ctx context.Context, tx bun.Tx) ([]string, error) { + var resourceTypes []string + err := tx.NewSelect(). + Model((*AuditLog)(nil)). + Column("resource_type"). + Distinct(). + Order("resource_type ASC"). + Scan(ctx, &resourceTypes) + if err != nil { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return resourceTypes, nil +} + // CleanupOldAuditLogs deletes audit logs older than the specified timestamp func CleanupOldAuditLogs(ctx context.Context, tx bun.Tx, olderThan int64) (int, error) { result, err := tx.NewDelete(). diff --git a/internal/db/getlist.go b/internal/db/getlist.go index cd414f0..c583c4e 100644 --- a/internal/db/getlist.go +++ b/internal/db/getlist.go @@ -20,8 +20,9 @@ type List[T any] struct { } type Filter struct { - Field string - Value any + Field string + Value any + Operator string } type ListFilter struct { @@ -32,8 +33,8 @@ func NewListFilter() *ListFilter { return &ListFilter{[]Filter{}} } -func (f *ListFilter) Add(field string, value any) { - f.filters = append(f.filters, Filter{field, value}) +func (f *ListFilter) Add(field, operator string, value any) { + f.filters = append(f.filters, Filter{field, value, "="}) } func GetList[T any](tx bun.Tx) *listgetter[T] { @@ -62,7 +63,7 @@ func (l *listgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *b func (l *listgetter[T]) Filter(filters ...Filter) *listgetter[T] { for _, filter := range filters { - l.q = l.q.Where("? = ?", bun.Ident(filter.Field), filter.Value) + l.q = l.q.Where("? ? ?", bun.Ident(filter.Field), bun.Safe(filter.Operator), filter.Value) } return l } diff --git a/internal/db/user.go b/internal/db/user.go index 6bb435b..a46c78c 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -141,3 +141,9 @@ func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], defaults := &PageOpts{1, 50, bun.OrderAsc, "id"} return GetList[User](tx).GetPaged(ctx, pageOpts, defaults) } + +// GetUsersWithRoles queries the database for users with their roles preloaded +func GetUsersWithRoles(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*List[User], error) { + defaults := &PageOpts{1, 25, bun.OrderAsc, "id"} + return GetList[User](tx).Relation("Roles").GetPaged(ctx, pageOpts, defaults) +} diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index fd20c43..205716f 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -12,7 +12,6 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; - --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; @@ -46,40 +45,11 @@ --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); + --blur-sm: 8px; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); --default-mono-font-family: var(--font-mono); - --color-rosewater: var(--rosewater); - --color-flamingo: var(--flamingo); - --color-pink: var(--pink); - --color-mauve: var(--mauve); - --color-red: var(--red); - --color-dark-red: var(--dark-red); - --color-maroon: var(--maroon); - --color-peach: var(--peach); - --color-yellow: var(--yellow); - --color-dark-yellow: var(--dark-yellow); - --color-green: var(--green); - --color-dark-green: var(--dark-green); - --color-teal: var(--teal); - --color-sky: var(--sky); - --color-sapphire: var(--sapphire); - --color-blue: var(--blue); - --color-dark-blue: var(--dark-blue); - --color-lavender: var(--lavender); - --color-text: var(--text); - --color-subtext1: var(--subtext1); - --color-subtext0: var(--subtext0); - --color-overlay2: var(--overlay2); - --color-overlay1: var(--overlay1); - --color-overlay0: var(--overlay0); - --color-surface2: var(--surface2); - --color-surface1: var(--surface1); - --color-surface0: var(--surface0); - --color-base: var(--base); - --color-mantle: var(--mantle); - --color-crust: var(--crust); } } @layer base { @@ -278,9 +248,6 @@ .top-0 { top: calc(var(--spacing) * 0); } - .top-1 { - top: calc(var(--spacing) * 1); - } .top-1\/2 { top: calc(1/2 * 100%); } @@ -305,12 +272,6 @@ .left-0 { left: calc(var(--spacing) * 0); } - .z-4 { - z-index: 4; - } - .z-7 { - z-index: 7; - } .z-10 { z-index: 10; } @@ -450,9 +411,6 @@ .h-1 { height: calc(var(--spacing) * 1); } - .h-2 { - height: calc(var(--spacing) * 2); - } .h-4 { height: calc(var(--spacing) * 4); } @@ -546,37 +504,18 @@ .max-w-sm { max-width: var(--container-sm); } - .max-w-xs { - max-width: var(--container-xs); - } .min-w-0 { min-width: calc(var(--spacing) * 0); } .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .flex-shrink-0 { flex-shrink: 0; } .shrink-0 { flex-shrink: 0; } - .flex-grow { - flex-grow: 1; - } - .grow { - flex-grow: 1; - } - .border-collapse { - border-collapse: collapse; - } - .-translate-y-1 { - --tw-translate-y: calc(var(--spacing) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } .-translate-y-1\/2 { --tw-translate-y: calc(calc(1/2 * 100%) * -1); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -589,6 +528,21 @@ --tw-translate-y: calc(var(--spacing) * 4); translate: var(--tw-translate-x) var(--tw-translate-y); } + .scale-95 { + --tw-scale-x: 95%; + --tw-scale-y: 95%; + --tw-scale-z: 95%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + .scale-100 { + --tw-scale-x: 100%; + --tw-scale-y: 100%; + --tw-scale-z: 100%; + scale: var(--tw-scale-x) var(--tw-scale-y); + } + .rotate-180 { + rotate: 180deg; + } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -604,9 +558,6 @@ .resize-none { resize: none; } - .appearance-none { - appearance: none; - } .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -694,16 +645,16 @@ border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); } } + .divide-surface1 { + :where(& > :not(:last-child)) { + border-color: var(--surface1); + } + } .divide-surface2 { :where(& > :not(:last-child)) { border-color: var(--surface2); } } - .truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } .overflow-hidden { overflow: hidden; } @@ -740,6 +691,10 @@ border-style: var(--tw-border-style); border-width: 2px; } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } .border-b { border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; @@ -756,6 +711,12 @@ .border-red { border-color: var(--red); } + .border-red\/30 { + border-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--red) 30%, transparent); + } + } .border-surface1 { border-color: var(--surface1); } @@ -777,10 +738,10 @@ .bg-blue { background-color: var(--blue); } - .bg-blue\/10 { + .bg-blue\/20 { background-color: var(--blue); @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--blue) 10%, transparent); + background-color: color-mix(in oklab, var(--blue) 20%, transparent); } } .bg-crust { @@ -804,10 +765,10 @@ .bg-green { background-color: var(--green); } - .bg-green\/10 { + .bg-green\/20 { background-color: var(--green); @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--green) 10%, transparent); + background-color: color-mix(in oklab, var(--green) 20%, transparent); } } .bg-mantle { @@ -828,6 +789,12 @@ background-color: color-mix(in oklab, var(--red) 10%, transparent); } } + .bg-red\/20 { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 20%, transparent); + } + } .bg-sapphire { background-color: var(--sapphire); } @@ -843,15 +810,12 @@ .bg-yellow { background-color: var(--yellow); } - .bg-yellow\/10 { + .bg-yellow\/20 { background-color: var(--yellow); @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--yellow) 10%, transparent); + background-color: color-mix(in oklab, var(--yellow) 20%, transparent); } } - .bg-no-repeat { - background-repeat: no-repeat; - } .p-1 { padding: calc(var(--spacing) * 1); } @@ -861,6 +825,9 @@ .p-2\.5 { padding: calc(var(--spacing) * 2.5); } + .p-3 { + padding: calc(var(--spacing) * 3); + } .p-4 { padding: calc(var(--spacing) * 4); } @@ -885,9 +852,6 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } - .py-0 { - padding-block: calc(var(--spacing) * 0); - } .py-0\.5 { padding-block: calc(var(--spacing) * 0.5); } @@ -912,9 +876,6 @@ .pr-2 { padding-right: calc(var(--spacing) * 2); } - .pr-8 { - padding-right: calc(var(--spacing) * 8); - } .pr-10 { padding-right: calc(var(--spacing) * 10); } @@ -1001,12 +962,12 @@ .break-all { word-break: break-all; } + .whitespace-nowrap { + white-space: nowrap; + } .whitespace-pre-wrap { white-space: pre-wrap; } - .text-base { - color: var(--base); - } .text-blue { color: var(--blue); } @@ -1046,9 +1007,6 @@ .italic { font-style: italic; } - .underline { - text-decoration-line: underline; - } .opacity-0 { opacity: 0%; } @@ -1070,13 +1028,14 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .backdrop-blur-sm { + --tw-backdrop-blur: blur(var(--blur-sm)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); @@ -1097,6 +1056,15 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } .duration-200 { --tw-duration: 200ms; transition-duration: 200ms; @@ -1145,6 +1113,16 @@ } } } + .hover\:bg-blue\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 80%, transparent); + } + } + } + } .hover\:bg-crust { &:hover { @media (hover: hover) { @@ -1192,6 +1170,16 @@ } } } + .hover\:bg-red\/80 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 80%, transparent); + } + } + } + } .hover\:bg-sapphire\/75 { &:hover { @media (hover: hover) { @@ -1295,6 +1283,11 @@ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } } + .focus\:ring-blue { + &:focus { + --tw-ring-color: var(--blue); + } + } .focus\:ring-mauve { &:focus { --tw-ring-color: var(--mauve); @@ -1500,6 +1493,26 @@ line-height: var(--tw-leading, var(--text-4xl--line-height)); } } + .md\:col-span-2 { + @media (width >= 48rem) { + grid-column: span 2 / span 2; + } + } + .md\:block { + @media (width >= 48rem) { + display: block; + } + } + .md\:hidden { + @media (width >= 48rem) { + display: none; + } + } + .md\:w-64 { + @media (width >= 48rem) { + width: calc(var(--spacing) * 64); + } + } .md\:grid-cols-2 { @media (width >= 48rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1510,6 +1523,11 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); } } + .md\:flex-row { + @media (width >= 48rem) { + flex-direction: row; + } + } .md\:gap-8 { @media (width >= 48rem) { gap: calc(var(--spacing) * 8); @@ -1530,6 +1548,11 @@ inset-inline-end: calc(var(--spacing) * 8); } } + .lg\:col-span-3 { + @media (width >= 64rem) { + grid-column: span 3 / span 3; + } + } .lg\:mt-0 { @media (width >= 64rem) { margin-top: calc(var(--spacing) * 0); @@ -1694,6 +1717,21 @@ inherits: false; initial-value: 0; } +@property --tw-scale-x { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-y { + syntax: "*"; + inherits: false; + initial-value: 1; +} +@property --tw-scale-z { + syntax: "*"; + inherits: false; + initial-value: 1; +} @property --tw-rotate-x { syntax: "*"; inherits: false; @@ -1806,11 +1844,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -1864,6 +1897,42 @@ syntax: "*"; inherits: false; } +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} @property --tw-duration { syntax: "*"; inherits: false; @@ -1872,27 +1941,15 @@ syntax: "*"; inherits: false; } -@property --tw-scale-x { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-y { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-z { - syntax: "*"; - inherits: false; - initial-value: 1; -} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { --tw-translate-x: 0; --tw-translate-y: 0; --tw-translate-z: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-scale-z: 1; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; @@ -1918,7 +1975,6 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -1932,11 +1988,17 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; --tw-duration: initial; --tw-ease: initial; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-scale-z: 1; } } } diff --git a/internal/embedfs/web/js/admin.js b/internal/embedfs/web/js/admin.js new file mode 100644 index 0000000..5a494af --- /dev/null +++ b/internal/embedfs/web/js/admin.js @@ -0,0 +1,35 @@ +// Admin dashboard utilities + +// Format JSON for display in modals +function formatJSON(json) { + try { + const parsed = typeof json === "string" ? JSON.parse(json) : json; + return JSON.stringify(parsed, null, 2); + } catch (e) { + return json; + } +} + +// Handle HTMX navigation for admin sections +document.addEventListener("DOMContentLoaded", function () { + // Update active nav item after HTMX navigation + document.body.addEventListener("htmx:afterSwap", function (event) { + if (event.detail.target.id === "admin-content") { + // Get the current URL path + const path = window.location.pathname; + const section = path.split("/").pop() || "users"; + + // Update active state on nav items + document.querySelectorAll("nav a").forEach(function (link) { + const href = link.getAttribute("href"); + if (href && href.includes("/" + section)) { + link.classList.remove("text-subtext0", "hover:bg-surface1", "hover:text-text"); + link.classList.add("bg-blue", "text-mantle", "font-semibold"); + } else { + link.classList.remove("bg-blue", "text-mantle", "font-semibold"); + link.classList.add("text-subtext0", "hover:bg-surface1", "hover:text-text"); + } + }); + } + }); +}); diff --git a/internal/handlers/admin_audit.go b/internal/handlers/admin_audit.go new file mode 100644 index 0000000..97ca4a7 --- /dev/null +++ b/internal/handlers/admin_audit.go @@ -0,0 +1,274 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/throw" + "git.haelnorr.com/h/oslstats/internal/validation" + adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// AdminAuditLogsPage renders the full admin dashboard page with audit logs section +func AdminAuditLogsPage(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var logs *db.List[db.AuditLog] + var users []*db.User + var actions []string + var resourceTypes []string + + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Get page options from query + pageOpts := pageOptsFromQuery(s, w, r) + if pageOpts == nil { + return false, nil + } + + // Get filters from query + filters, ok := getAuditFiltersFromQuery(s, w, r) + if !ok { + return false, nil + } + + // Get audit logs + logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters) + if err != nil { + return false, errors.Wrap(err, "db.GetAuditLogs") + } + + // Get all users for filter dropdown + usersList, err := db.GetUsers(ctx, tx, nil) + if err != nil { + return false, errors.Wrap(err, "db.GetUsers") + } + users = usersList.Items + + // Get unique actions + actions, err = db.GetUniqueActions(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetUniqueActions") + } + + // Get unique resource types + resourceTypes, err = db.GetUniqueResourceTypes(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetUniqueResourceTypes") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.AuditLogsPage(logs, users, actions, resourceTypes), s, r, w) + }) +} + +// AdminAuditLogsList shows audit logs (HTMX content replacement - full section with filters) +func AdminAuditLogsList(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var logs *db.List[db.AuditLog] + var users []*db.User + var actions []string + var resourceTypes []string + + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Get page options from form + pageOpts := pageOptsFromForm(s, w, r) + if pageOpts == nil { + return false, nil + } + + // No filters for initial section load + filters := db.NewAuditLogFilter() + + // Get audit logs + logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters) + if err != nil { + return false, errors.Wrap(err, "db.GetAuditLogs") + } + + // Get all users for filter dropdown + usersList, err := db.GetUsers(ctx, tx, nil) + if err != nil { + return false, errors.Wrap(err, "db.GetUsers") + } + users = usersList.Items + + // Get unique actions + actions, err = db.GetUniqueActions(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetUniqueActions") + } + + // Get unique resource types + resourceTypes, err = db.GetUniqueResourceTypes(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.GetUniqueResourceTypes") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.AuditLogsList(logs, users, actions, resourceTypes), s, r, w) + }) +} + +// AdminAuditLogsFilter handles filter requests and returns only the results table +func AdminAuditLogsFilter(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var logs *db.List[db.AuditLog] + + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + + // Get page options from form + pageOpts := pageOptsFromForm(s, w, r) + if pageOpts == nil { + return false, nil + } + + // Get filters from form + filters, ok := getAuditFiltersFromForm(s, w, r) + if !ok { + return false, nil + } + + // Get audit logs + logs, err = db.GetAuditLogs(ctx, tx, pageOpts, filters) + if err != nil { + return false, errors.Wrap(err, "db.GetAuditLogs") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.AuditLogsResults(logs), s, r, w) + }) +} + +// AdminAuditLogDetail shows details for a single audit log entry +func AdminAuditLogDetail(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get ID from path + idStr := r.PathValue("id") + if idStr == "" { + throw.BadRequest(s, w, r, "Missing audit log ID", nil) + return + } + + id, err := strconv.Atoi(idStr) + if err != nil { + throw.BadRequest(s, w, r, "Invalid audit log ID", err) + return + } + + var log *db.AuditLog + + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + log, err = db.GetAuditLogByID(ctx, tx, id) + if err != nil { + return false, errors.Wrap(err, "db.GetAuditLogByID") + } + if log == nil { + throw.NotFound(s, w, r, r.URL.Path) + return false, nil + } + return true, nil + }); !ok { + return + } + + renderSafely(adminview.AuditLogDetail(log), s, r, w) + }) +} + +// getAuditFiltersFromQuery extracts audit log filters from query string +func getAuditFiltersFromQuery(s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) { + g := validation.NewQueryGetter(r) + return buildAuditFilters(g, s, w, r) +} + +// getAuditFiltersFromForm extracts audit log filters from form data +func getAuditFiltersFromForm(s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) { + g, ok := validation.ParseFormOrError(s, w, r) + if !ok { + return nil, false + } + return buildAuditFilters(g, s, w, r) +} + +// buildAuditFilters builds audit log filters from a validation.Getter +func buildAuditFilters(g validation.Getter, s *hws.Server, w http.ResponseWriter, r *http.Request) (*db.AuditLogFilter, bool) { + filters := db.NewAuditLogFilter() + + // User ID filter (optional) + userID := g.Int("user_id").Optional().Min(1).Value + + // Action filter (optional) + action := g.String("action").TrimSpace().Optional().Value + + // Resource Type filter (optional) + resourceType := g.String("resource_type").TrimSpace().Optional().Value + + // Result filter (optional) + result := g.String("result").TrimSpace().Optional().AllowedValues([]string{"success", "denied", "error"}).Value + + // Date range filter (optional) + startDateStr := g.String("start_date").TrimSpace().Optional().Value + endDateStr := g.String("end_date").TrimSpace().Optional().Value + + // Validate + if !g.ValidateAndError(s, w, r) { + return nil, false + } + + // Apply filters + if userID > 0 { + filters.UserID(userID) + } + + if action != "" { + filters.Action(action) + } + + if resourceType != "" { + filters.ResourceType(resourceType) + } + + if result != "" { + filters.Result(result) + } + + // Parse and apply date range + if startDateStr != "" { + if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil { + filters.DateRange(startDate.Unix(), 0) + } + } + + if endDateStr != "" { + if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil { + // Set to end of day + endOfDay := endDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + filters.DateRange(0, endOfDay.Unix()) + } + } + + return filters, true +} diff --git a/internal/handlers/admin_dashboard.go b/internal/handlers/admin_dashboard.go index 25404e7..5639199 100644 --- a/internal/handlers/admin_dashboard.go +++ b/internal/handlers/admin_dashboard.go @@ -11,14 +11,15 @@ import ( "github.com/uptrace/bun" ) +// AdminDashboard renders the full admin dashboard page (defaults to users section) func AdminDashboard(s *hws.Server, conn *bun.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var users *db.List[db.User] if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - users, err = db.GetUsers(ctx, tx, nil) + users, err = db.GetUsersWithRoles(ctx, tx, nil) if err != nil { - return false, errors.Wrap(err, "db.GetUsers") + return false, errors.Wrap(err, "db.GetUsersWithRoles") } return true, nil }); !ok { diff --git a/internal/handlers/admin_permissions.go b/internal/handlers/admin_permissions.go new file mode 100644 index 0000000..40714c8 --- /dev/null +++ b/internal/handlers/admin_permissions.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" + "github.com/uptrace/bun" +) + +// AdminPermissionsPage renders the full admin dashboard page with permissions section +func AdminPermissionsPage(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Load permissions from database + renderSafely(adminview.PermissionsPage(), s, r, w) + }) +} + +// AdminPermissionsList shows all permissions (HTMX content replacement) +func AdminPermissionsList(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Load permissions from database + renderSafely(adminview.PermissionsList(), s, r, w) + }) +} diff --git a/internal/handlers/admin_roles.go b/internal/handlers/admin_roles.go new file mode 100644 index 0000000..b3da630 --- /dev/null +++ b/internal/handlers/admin_roles.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + adminview "git.haelnorr.com/h/oslstats/internal/view/adminview" + "github.com/uptrace/bun" +) + +// AdminRolesPage renders the full admin dashboard page with roles section +func AdminRolesPage(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Load roles from database + renderSafely(adminview.RolesPage(), s, r, w) + }) +} + +// AdminRolesList shows all roles (HTMX content replacement) +func AdminRolesList(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Load roles from database + renderSafely(adminview.RolesList(), s, r, w) + }) +} diff --git a/internal/handlers/admin_users.go b/internal/handlers/admin_users.go index 53bc156..f02ccf3 100644 --- a/internal/handlers/admin_users.go +++ b/internal/handlers/admin_users.go @@ -11,19 +11,34 @@ import ( "github.com/uptrace/bun" ) -// AdminUsersList shows all users +// AdminUsersPage renders the full admin dashboard page with users section +func AdminUsersPage(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var users *db.List[db.User] + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + users, err = db.GetUsersWithRoles(ctx, tx, nil) + if err != nil { + return false, errors.Wrap(err, "db.GetUsersWithRoles") + } + return true, nil + }); !ok { + return + } + renderSafely(adminview.DashboardPage(users), s, r, w) + }) +} + +// AdminUsersList shows all users (HTMX content replacement) func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var users *db.List[db.User] - pageOpts := pageOptsFromForm(s, w, r) - if pageOpts == nil { - return - } if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - users, err = db.GetUsers(ctx, tx, pageOpts) + // Get users with their roles + users, err = db.GetUsersWithRoles(ctx, tx, nil) if err != nil { - return false, errors.Wrap(err, "db.GetUsers") + return false, errors.Wrap(err, "db.GetUsersWithRoles") } return true, nil }); !ok { diff --git a/internal/view/adminview/audit_log_detail.templ b/internal/view/adminview/audit_log_detail.templ new file mode 100644 index 0000000..de6aceb --- /dev/null +++ b/internal/view/adminview/audit_log_detail.templ @@ -0,0 +1,156 @@ +package adminview + +import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" +import "time" +import "encoding/json" + +templ AuditLogDetail(log *db.AuditLog) { + +
+ +
+ +
+

Audit Log Details

+ +
+ +
+ +
+ +

{ fmt.Sprintf("%d", log.ID) }

+
+ +
+ +

+ if log.User != nil { + { log.User.Username } (ID: { fmt.Sprintf("%d", log.UserID) }) + } else { + Unknown User (ID: { fmt.Sprintf("%d", log.UserID) }) + } +

+
+ +
+ +

{ formatDetailTimestamp(log.CreatedAt) }

+
+ +
+ +

{ log.Action }

+
+ +
+ +

{ log.ResourceType }

+
+ +
+ +

+ if log.ResourceID != nil { + { *log.ResourceID } + } else { + N/A + } +

+
+ +
+ +
+ @resultBadge(log.Result) +
+
+ + if log.ErrorMessage != nil && *log.ErrorMessage != "" { +
+ +
+

{ *log.ErrorMessage }

+
+
+ } + +
+ +

{ log.IPAddress }

+
+ +
+ +

{ log.UserAgent }

+
+ + if log.Details != nil && len(log.Details) > 0 && string(log.Details) != "null" { +
+ +
+
{ formatJSON(log.Details) }
+
+
+ } +
+ +
+ +
+
+
+} + +func formatDetailTimestamp(unixTime int64) string { + t := time.Unix(unixTime, 0) + return t.Format("Monday, January 2, 2006 at 3:04:05 PM MST") +} + +func formatJSON(raw []byte) string { + if len(raw) == 0 || string(raw) == "null" { + return "No details available" + } + // Pretty print the JSON + var obj interface{} + if err := json.Unmarshal(raw, &obj); err != nil { + return string(raw) + } + pretty, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return string(raw) + } + return string(pretty) +} diff --git a/internal/view/adminview/audit_logs_list.templ b/internal/view/adminview/audit_logs_list.templ new file mode 100644 index 0000000..45d6c1c --- /dev/null +++ b/internal/view/adminview/audit_logs_list.templ @@ -0,0 +1,234 @@ +package adminview + +import ( + "git.haelnorr.com/h/oslstats/internal/db" + "fmt" + "time" +) + +templ AuditLogsList(logs *db.List[db.AuditLog], users []*db.User, actions []string, resourceTypes []string) { +
+ +
+

Audit Logs

+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+ @AuditLogsResults(logs) +
+
+ + +} + +templ AuditLogsResults(logs *db.List[db.AuditLog]) { + if len(logs.Items) == 0 { +
+

No audit logs found

+
+ } else { +
+
+ + + + + + + + + + + + + + for _, log := range logs.Items { + + + + + + + + + + } + +
TimestampUserActionResourceResource IDResultActions
+ { formatFullTimestamp(log.CreatedAt) } + + if log.User != nil { + { log.User.Username } + } else { + Unknown + } + + { log.Action } + + { log.ResourceType } + + if log.ResourceID != nil { + { *log.ResourceID } + } else { + + } + + @resultBadge(log.Result) + + +
+
+
+ + {{ + totalPages := (logs.Total + logs.PageOpts.PerPage - 1) / logs.PageOpts.PerPage + if logs.PageOpts.PerPage == 0 { + totalPages = 1 + } + }} + if totalPages > 1 { +
+ if logs.PageOpts.Page > 1 { + + } + + Page { fmt.Sprintf("%d", logs.PageOpts.Page) } of { fmt.Sprintf("%d", totalPages) } + + if logs.PageOpts.Page < totalPages { + + } +
+ } + } +} + +templ resultBadge(result string) { + {{ + var classes string + switch result { + case "success": + classes = "px-2 py-0.5 bg-green/20 text-green rounded text-xs font-medium" + case "denied": + classes = "px-2 py-0.5 bg-yellow/20 text-yellow rounded text-xs font-medium" + case "error": + classes = "px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium" + default: + classes = "px-2 py-0.5 bg-surface1 text-subtext0 rounded text-xs font-medium" + } + }} + { result } +} + +func formatFullTimestamp(unixTime int64) string { + t := time.Unix(unixTime, 0) + return t.Format("Jan 2, 2006 15:04:05") +} diff --git a/internal/view/adminview/audit_page.templ b/internal/view/adminview/audit_page.templ new file mode 100644 index 0000000..7558387 --- /dev/null +++ b/internal/view/adminview/audit_page.templ @@ -0,0 +1,9 @@ +package adminview + +import "git.haelnorr.com/h/oslstats/internal/db" + +templ AuditLogsPage(logs *db.List[db.AuditLog], users []*db.User, actions []string, resourceTypes []string) { + @DashboardLayout("audit") { + @AuditLogsList(logs, users, actions, resourceTypes) + } +} diff --git a/internal/view/adminview/dashboard_layout.templ b/internal/view/adminview/dashboard_layout.templ index 3ee2d00..533fdc2 100644 --- a/internal/view/adminview/dashboard_layout.templ +++ b/internal/view/adminview/dashboard_layout.templ @@ -2,10 +2,74 @@ package adminview import "git.haelnorr.com/h/oslstats/internal/view/baseview" -templ DashboardLayout() { - @baseview.Layout("Admin") { -
- { children... } +templ DashboardLayout(activeSection string) { + @baseview.Layout("Admin Dashboard") { +
+
+ + + +
+ { children... } +
+
+ } } + +templ navItem(section string, label string, activeSection string) { + {{ + isActive := section == activeSection + baseClasses := "block px-4 py-2 rounded-lg transition-colors cursor-pointer" + activeClasses := "bg-blue text-mantle font-semibold" + inactiveClasses := "text-subtext0 hover:bg-surface1 hover:text-text" + }} +
  • + + { label } + +
  • +} diff --git a/internal/view/adminview/dashboard_page.templ b/internal/view/adminview/dashboard_page.templ index 0c78287..38d46c4 100644 --- a/internal/view/adminview/dashboard_page.templ +++ b/internal/view/adminview/dashboard_page.templ @@ -3,7 +3,7 @@ package adminview import "git.haelnorr.com/h/oslstats/internal/db" templ DashboardPage(users *db.List[db.User]) { - @DashboardLayout() { + @DashboardLayout("users") { @UserList(users) } } diff --git a/internal/view/adminview/permissions_list.templ b/internal/view/adminview/permissions_list.templ new file mode 100644 index 0000000..c491712 --- /dev/null +++ b/internal/view/adminview/permissions_list.templ @@ -0,0 +1,14 @@ +package adminview + +templ PermissionsList() { +
    + +
    +

    Permission Management

    +
    + +
    +

    Permissions management coming soon...

    +
    +
    +} diff --git a/internal/view/adminview/permissions_page.templ b/internal/view/adminview/permissions_page.templ new file mode 100644 index 0000000..cd910a3 --- /dev/null +++ b/internal/view/adminview/permissions_page.templ @@ -0,0 +1,7 @@ +package adminview + +templ PermissionsPage() { + @DashboardLayout("permissions") { + @PermissionsList() + } +} diff --git a/internal/view/adminview/roles_list.templ b/internal/view/adminview/roles_list.templ new file mode 100644 index 0000000..c7680cd --- /dev/null +++ b/internal/view/adminview/roles_list.templ @@ -0,0 +1,14 @@ +package adminview + +templ RolesList() { +
    + +
    +

    Role Management

    +
    + +
    +

    Roles management coming soon...

    +
    +
    +} diff --git a/internal/view/adminview/roles_page.templ b/internal/view/adminview/roles_page.templ new file mode 100644 index 0000000..b1829aa --- /dev/null +++ b/internal/view/adminview/roles_page.templ @@ -0,0 +1,7 @@ +package adminview + +templ RolesPage() { + @DashboardLayout("roles") { + @RolesList() + } +} diff --git a/internal/view/adminview/user_list.templ b/internal/view/adminview/user_list.templ index 854f0a0..e18c085 100644 --- a/internal/view/adminview/user_list.templ +++ b/internal/view/adminview/user_list.templ @@ -1,6 +1,74 @@ package adminview import "git.haelnorr.com/h/oslstats/internal/db" +import "fmt" +import "time" templ UserList(users *db.List[db.User]) { +
    + +
    +

    User Management

    +
    + + if len(users.Items) == 0 { +
    +

    No users found

    +
    + } else { +
    +
    + + + + + + + + + + + + + for _, user := range users.Items { + + + + + + + + + } + +
    IDUsernameDiscord IDRolesCreatedActions
    { fmt.Sprintf("%d", user.ID) }{ user.Username }{ user.DiscordID } + if len(user.Roles) > 0 { +
    + for _, role := range user.Roles { + + { role.DisplayName } + + } +
    + } else { + No roles + } +
    + { formatTimestamp(user.CreatedAt) } + + +
    +
    +
    + } +
    +} + +func formatTimestamp(unixTime int64) string { + t := time.Unix(unixTime, 0) + return t.Format("Jan 2, 2006") } diff --git a/internal/view/seasonsview/detail_page.templ b/internal/view/seasonsview/detail_page.templ index 2ff030d..7f69827 100644 --- a/internal/view/seasonsview/detail_page.templ +++ b/internal/view/seasonsview/detail_page.templ @@ -187,13 +187,14 @@ func formatDuration(start, end time.Time) string { } templ SlapVersionBadge(version string) { - if version == "rebound" { - - Rebound - - } else if version == "slapshot1" { - - Slapshot 1 - + switch version { + case "rebound": + + Rebound + + case "slapshot1": + + Slapshot 1 + } }