diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index 46acaac..a6b7b44 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -75,7 +75,7 @@ func setupHTTPServer( return nil, errors.Wrap(err, "addRoutes") } - err = addMiddleware(httpServer, auth, cfg, perms, discordAPI, store) + err = addMiddleware(httpServer, auth, cfg, perms, discordAPI, store, bun) if err != nil { return nil, errors.Wrap(err, "addMiddleware") } diff --git a/cmd/oslstats/middleware.go b/cmd/oslstats/middleware.go index fdcd73e..c4b701b 100644 --- a/cmd/oslstats/middleware.go +++ b/cmd/oslstats/middleware.go @@ -27,9 +27,11 @@ func addMiddleware( perms *rbac.Checker, discordAPI *discord.APIClient, store *store.Store, + conn *bun.DB, ) error { err := server.AddMiddleware( auth.Authenticate(tokenRefresh(auth, discordAPI, store)), + rbac.LoadPreviewRoleMiddleware(server, conn), perms.LoadPermissionsMiddleware(), devMode(cfg), ) diff --git a/cmd/oslstats/migrate.go b/cmd/oslstats/migrate.go index 75d50ad..85e1fbd 100644 --- a/cmd/oslstats/migrate.go +++ b/cmd/oslstats/migrate.go @@ -229,26 +229,22 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error { fmt.Println("║ DATABASE MIGRATION STATUS ║") fmt.Println("╚══════════════════════════════════════════════════════════╝") - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - _, _ = fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tMIGRATED AT") - _, _ = fmt.Fprintln(w, "------\t---------\t-----\t-----------") + w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) + _, _ = fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tCOMMENT") + _, _ = fmt.Fprintln(w, "----------\t---------------\t-----\t---------------------------") appliedCount := 0 for _, m := range ms { status := "⏳ Pending" - migratedAt := "-" group := "-" if m.GroupID > 0 { status = "✅ Applied" appliedCount++ group = fmt.Sprint(m.GroupID) - if !m.MigratedAt.IsZero() { - migratedAt = m.MigratedAt.Format("2006-01-02 15:04:05") - } } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, migratedAt) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, m.Comment) } _ = w.Flush() @@ -357,12 +353,12 @@ import ( func init() { Migrations.MustRegister( // UP migration - func(ctx context.Context, dbConn *bun.DB) error { + func(ctx context.Context, conn *bun.DB) error { // Add your migration code here return nil }, // DOWN migration - func(ctx context.Context, dbConn *bun.DB) error { + func(ctx context.Context, conn *bun.DB) error { // Add your rollback code here return nil }, @@ -378,7 +374,7 @@ func init() { fmt.Printf("✅ Created migration: %s\n", filename) fmt.Println("📝 Next steps:") fmt.Println(" 1. Edit the file and implement the UP and DOWN functions") - fmt.Println(" 2. Run: make migrate") + fmt.Println(" 2. Run: just migrate up") return nil } diff --git a/cmd/oslstats/migrations/20260213162216_missing_permissions.go b/cmd/oslstats/migrations/20260213162216_missing_permissions.go new file mode 100644 index 0000000..ccbd3c8 --- /dev/null +++ b/cmd/oslstats/migrations/20260213162216_missing_permissions.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "time" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + // Add your migration code here + now := time.Now().Unix() + permissionsData := []*db.Permission{ + {Name: "seasons.add_league", DisplayName: "Add Leagues to Season", Description: "Assign an existing league to Seasons", Resource: "seasons", Action: "add_league", IsSystem: true, CreatedAt: now}, + {Name: "seasons.remove_league", DisplayName: "Remove Leagues from a Season", Description: "Remove an assigned league league from Seasons", Resource: "seasons", Action: "remove_league", IsSystem: true, CreatedAt: now}, + {Name: "leagues.create", DisplayName: "Create Leagues", Description: "Create new leagues", Resource: "leagues", Action: "create", IsSystem: true, CreatedAt: now}, + {Name: "leagues.update", DisplayName: "Update Leagues", Description: "Update existing leagues", Resource: "leagues", Action: "update", IsSystem: true, CreatedAt: now}, + {Name: "leagues.delete", DisplayName: "Delete Leagues", Description: "Delete leagues", Resource: "leagues", Action: "delete", IsSystem: true, CreatedAt: now}, + {Name: "teams.create", DisplayName: "Create Teams", Description: "Create new teams", Resource: "teams", Action: "create", IsSystem: true, CreatedAt: now}, + {Name: "teams.update", DisplayName: "Update Teams", Description: "Update existing teams", Resource: "teams", Action: "update", IsSystem: true, CreatedAt: now}, + {Name: "teams.delete", DisplayName: "Delete Teams", Description: "Delete teams", Resource: "teams", Action: "delete", IsSystem: true, CreatedAt: now}, + {Name: "teams.add_to_league", DisplayName: "Add Teams to League", Description: "Add an existing team to a league/season", Resource: "teams", Action: "add_to_league", IsSystem: true, CreatedAt: now}, + } + + _, err := conn.NewInsert(). + Model(&permissionsData). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "dbConn.NewInsert") + } + return nil + }, + // DOWN migration + func(ctx context.Context, dbConn *bun.DB) error { + // Add your rollback code here + return nil + }, + ) +} diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 9b0abe1..c461f5a 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -214,13 +214,13 @@ func addRoutes( }, { Path: "/admin/users", - Method: hws.MethodGET, + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, Handler: perms.RequireAdmin(s)(handlers.AdminUsersPage(s, conn)), }, { Path: "/admin/roles", - Method: hws.MethodGET, - Handler: perms.RequireAdmin(s)(handlers.AdminRolesPage(s, conn)), + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: perms.RequireAdmin(s)(handlers.AdminRoles(s, conn)), }, { Path: "/admin/permissions", @@ -232,17 +232,6 @@ func addRoutes( 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, @@ -253,6 +242,52 @@ func addRoutes( Method: hws.MethodPOST, Handler: perms.RequireAdmin(s)(handlers.AdminAuditLogsList(s, conn)), }, + // Role management routes + { + Path: "/admin/roles/create", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminRoleCreateForm(s)), + }, + { + Path: "/admin/roles/create", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminRoleCreate(s, conn, audit)), + }, + { + Path: "/admin/roles/{id}/manage", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminRoleManage(s, conn)), + }, + { + Path: "/admin/roles/{id}", + Method: hws.MethodDELETE, + Handler: perms.RequireAdmin(s)(handlers.AdminRoleDelete(s, conn, audit)), + }, + { + Path: "/admin/roles/{id}/delete-confirm", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminRoleDeleteConfirm(s, conn)), + }, + { + Path: "/admin/roles/{id}/permissions", + Method: hws.MethodGET, + Handler: perms.RequireAdmin(s)(handlers.AdminRolePermissionsModal(s, conn)), + }, + { + Path: "/admin/roles/{id}/permissions", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminRolePermissionsUpdate(s, conn, audit)), + }, + { + Path: "/admin/roles/{id}/preview-start", + Method: hws.MethodPOST, + Handler: perms.RequireAdmin(s)(handlers.AdminPreviewRoleStart(s, conn, cfg)), + }, + { + Path: "/admin/roles/preview-stop", + Method: hws.MethodPOST, + Handler: perms.RequireActualAdmin(s)(handlers.AdminPreviewRoleStop(s)), + }, // Audit log filtering (returns only results table, no URL push) { Path: "/admin/audit/filter", diff --git a/internal/contexts/keys.go b/internal/contexts/keys.go index 74fbe70..561f47b 100644 --- a/internal/contexts/keys.go +++ b/internal/contexts/keys.go @@ -10,4 +10,5 @@ func (c Key) String() string { var ( DevModeKey Key = Key("devmode") PermissionCacheKey Key = Key("permissions") + PreviewRoleKey Key = Key("preview-role") ) diff --git a/internal/contexts/preview_role.go b/internal/contexts/preview_role.go new file mode 100644 index 0000000..c8513bc --- /dev/null +++ b/internal/contexts/preview_role.go @@ -0,0 +1,25 @@ +package contexts + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" +) + +// WithPreviewRole adds a preview role to the context +func WithPreviewRole(ctx context.Context, role *db.Role) context.Context { + return context.WithValue(ctx, PreviewRoleKey, role) +} + +// GetPreviewRole retrieves the preview role from the context, or nil if not present +func GetPreviewRole(ctx context.Context) *db.Role { + if role, ok := ctx.Value(PreviewRoleKey).(*db.Role); ok { + return role + } + return nil +} + +// IsPreviewMode returns true if the user is currently in preview mode +func IsPreviewMode(ctx context.Context) bool { + return GetPreviewRole(ctx) != nil +} diff --git a/internal/db/role.go b/internal/db/role.go index 12f88c6..b018fbe 100644 --- a/internal/db/role.go +++ b/internal/db/role.go @@ -98,10 +98,43 @@ func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error { } // DeleteRole deletes a role (checks IsSystem protection) +// Also cleans up join table entries in role_permissions and user_roles func DeleteRole(ctx context.Context, tx bun.Tx, id int) error { if id <= 0 { return errors.New("id must be positive") } + + // First check if role exists and is not system + role, err := GetRoleByID(ctx, tx, id) + if err != nil { + return errors.Wrap(err, "GetRoleByID") + } + if role == nil { + return errors.New("role not found") + } + if role.IsSystem { + return errors.New("cannot delete system roles") + } + + // Delete role_permissions entries + _, err = tx.NewDelete(). + Model((*RolePermission)(nil)). + Where("role_id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "delete role_permissions") + } + + // Delete user_roles entries + _, err = tx.NewDelete(). + Model((*UserRole)(nil)). + Where("role_id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "delete user_roles") + } + + // Finally delete the role return DeleteWithProtection[Role](ctx, tx, id) } diff --git a/internal/embedfs/web/css/output.css b/internal/embedfs/web/css/output.css index ede2d4d..5c6b35a 100644 --- a/internal/embedfs/web/css/output.css +++ b/internal/embedfs/web/css/output.css @@ -16,6 +16,7 @@ --container-md: 28rem; --container-lg: 32rem; --container-2xl: 42rem; + --container-3xl: 48rem; --container-5xl: 64rem; --container-7xl: 80rem; --text-xs: 0.75rem; @@ -40,11 +41,13 @@ --font-weight-semibold: 600; --font-weight-bold: 700; --tracking-tight: -0.025em; + --tracking-wider: 0.05em; --leading-relaxed: 1.625; --radius-lg: 0.5rem; --radius-xl: 0.75rem; --ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, 0.2, 1); + --animate-spin: spin 1s linear infinite; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -304,6 +307,9 @@ .-mt-2 { margin-top: calc(var(--spacing) * -2); } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } .mt-1 { margin-top: calc(var(--spacing) * 1); } @@ -467,8 +473,8 @@ .w-26 { width: calc(var(--spacing) * 26); } - .w-36 { - width: calc(var(--spacing) * 36); + .w-48 { + width: calc(var(--spacing) * 48); } .w-80 { width: calc(var(--spacing) * 80); @@ -482,6 +488,9 @@ .max-w-2xl { max-width: var(--container-2xl); } + .max-w-3xl { + max-width: var(--container-3xl); + } .max-w-5xl { max-width: var(--container-5xl); } @@ -548,12 +557,12 @@ --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,); } + .animate-spin { + animation: var(--animate-spin); + } .cursor-not-allowed { cursor: not-allowed; } @@ -645,9 +654,37 @@ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-6 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-2 { column-gap: calc(var(--spacing) * 2); } + .space-x-2 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-3 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse))); + } + } + .space-x-4 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } .gap-y-4 { row-gap: calc(var(--spacing) * 4); } @@ -722,6 +759,10 @@ border-bottom-style: var(--tw-border-style); border-bottom-width: 1px; } + .border-b-2 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 2px; + } .border-blue { border-color: var(--blue); } @@ -743,12 +784,27 @@ .border-surface1 { border-color: var(--surface1); } + .border-surface2 { + border-color: var(--surface2); + } .border-transparent { border-color: transparent; } .border-yellow { border-color: var(--yellow); } + .border-yellow\/30 { + border-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--yellow) 30%, transparent); + } + } + .border-yellow\/40 { + border-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--yellow) 40%, transparent); + } + } .bg-base { background-color: var(--base); } @@ -827,12 +883,21 @@ .bg-surface1 { background-color: var(--surface1); } + .bg-surface2 { + background-color: var(--surface2); + } .bg-teal { background-color: var(--teal); } .bg-yellow { background-color: var(--yellow); } + .bg-yellow\/10 { + background-color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--yellow) 10%, transparent); + } + } .bg-yellow\/20 { background-color: var(--yellow); @supports (color: color-mix(in lab, red, red)) { @@ -887,18 +952,27 @@ .py-3 { padding-block: calc(var(--spacing) * 3); } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } .py-6 { padding-block: calc(var(--spacing) * 6); } .py-8 { padding-block: calc(var(--spacing) * 8); } + .pt-2 { + padding-top: calc(var(--spacing) * 2); + } .pt-4 { padding-top: calc(var(--spacing) * 4); } .pt-5 { padding-top: calc(var(--spacing) * 5); } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } .pr-2 { padding-right: calc(var(--spacing) * 2); } @@ -985,6 +1059,10 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); + } .text-wrap { text-wrap: wrap; } @@ -1027,6 +1105,15 @@ .text-yellow { color: var(--yellow); } + .text-yellow\/80 { + color: var(--yellow); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--yellow) 80%, transparent); + } + } + .capitalize { + text-transform: capitalize; + } .lowercase { text-transform: lowercase; } @@ -1080,11 +1167,6 @@ 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; @@ -1127,6 +1209,13 @@ } } } + .hover\:border-surface2 { + &:hover { + @media (hover: hover) { + border-color: var(--surface2); + } + } + } .hover\:bg-blue\/75 { &:hover { @media (hover: hover) { @@ -1164,6 +1253,13 @@ } } } + .hover\:bg-maroon { + &:hover { + @media (hover: hover) { + background-color: var(--maroon); + } + } + } .hover\:bg-mauve\/75 { &:hover { @media (hover: hover) { @@ -1204,6 +1300,13 @@ } } } + .hover\:bg-sky { + &:hover { + @media (hover: hover) { + background-color: var(--sky); + } + } + } .hover\:bg-surface0 { &:hover { @media (hover: hover) { @@ -1218,6 +1321,16 @@ } } } + .hover\:bg-surface1\/50 { + &:hover { + @media (hover: hover) { + background-color: var(--surface1); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--surface1) 50%, transparent); + } + } + } + } .hover\:bg-surface2 { &:hover { @media (hover: hover) { @@ -1225,6 +1338,13 @@ } } } + .hover\:bg-teal { + &:hover { + @media (hover: hover) { + background-color: var(--teal); + } + } + } .hover\:bg-teal\/75 { &:hover { @media (hover: hover) { @@ -1304,6 +1424,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); @@ -1517,31 +1642,11 @@ 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)); } } - .md\:flex-row { - @media (width >= 48rem) { - flex-direction: row; - } - } .md\:gap-8 { @media (width >= 48rem) { gap: calc(var(--spacing) * 8); @@ -1766,6 +1871,11 @@ inherits: false; initial-value: 0; } +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-divide-y-reverse { syntax: "*"; inherits: false; @@ -1914,6 +2024,11 @@ syntax: "*"; inherits: false; } +@keyframes spin { + to { + transform: rotate(360deg); + } +} @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 { @@ -1929,6 +2044,7 @@ --tw-skew-x: initial; --tw-skew-y: initial; --tw-space-y-reverse: 0; + --tw-space-x-reverse: 0; --tw-divide-y-reverse: 0; --tw-border-style: solid; --tw-leading: initial; diff --git a/internal/handlers/admin_preview_role.go b/internal/handlers/admin_preview_role.go new file mode 100644 index 0000000..4f07199 --- /dev/null +++ b/internal/handlers/admin_preview_role.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/rbac" + "git.haelnorr.com/h/oslstats/internal/roles" + "git.haelnorr.com/h/oslstats/internal/throw" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// AdminPreviewRoleStart starts preview mode for a specific role +func AdminPreviewRoleStart(s *hws.Server, conn *bun.DB, cfg *config.Config) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get role ID from URL + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + throw.BadRequest(s, w, r, "Invalid role ID", err) + return + } + + // Verify role exists and is not admin + var role *db.Role + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + role, err = db.GetRoleByID(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleByID") + } + if role == nil { + throw.NotFound(s, w, r, "Role not found") + return false, nil + } + // Cannot preview admin role + if role.Name == roles.Admin { + throw.BadRequest(s, w, r, "Cannot preview admin role", nil) + return false, nil + } + return true, nil + }); !ok { + return + } + + // Set preview role cookie + rbac.SetPreviewRoleCookie(w, roleID, cfg.HWSAuth.SSL) + + // Redirect to home page + http.Redirect(w, r, "/", http.StatusSeeOther) + }) +} + +// AdminPreviewRoleStop stops preview mode and returns to normal view +func AdminPreviewRoleStop(s *hws.Server) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Clear preview role cookie + rbac.ClearPreviewRoleCookie(w) + + // Check if we should stay on current page or redirect to admin + stay := r.URL.Query().Get("stay") + + if stay == "true" { + // Get referer to redirect back to current page + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + } else { + // Redirect to admin roles page + http.Redirect(w, r, "/admin/roles", http.StatusSeeOther) + } + }) +} diff --git a/internal/handlers/admin_roles.go b/internal/handlers/admin_roles.go index b3da630..2c8e335 100644 --- a/internal/handlers/admin_roles.go +++ b/internal/handlers/admin_roles.go @@ -1,25 +1,371 @@ package handlers import ( + "context" "net/http" + "sort" + "strconv" + "time" "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/auditlog" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/roles" + "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" ) -// AdminRolesPage renders the full admin dashboard page with roles section -func AdminRolesPage(s *hws.Server, conn *bun.DB) http.Handler { +// AdminRoles renders the full admin dashboard page with roles section +func AdminRoles(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) + var rolesList []*db.Role + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + rolesList, err = db.ListAllRoles(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllRoles") + } + return true, nil + }); !ok { + return + } + + if r.Method == "GET" { + renderSafely(adminview.RolesPage(rolesList), s, r, w) + } else { + renderSafely(adminview.RolesList(rolesList), s, r, w) + } }) } -// AdminRolesList shows all roles (HTMX content replacement) -func AdminRolesList(s *hws.Server, conn *bun.DB) http.Handler { +// AdminRoleCreateForm shows the create role form modal +func AdminRoleCreateForm(s *hws.Server) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: Load roles from database - renderSafely(adminview.RolesList(), s, r, w) + renderSafely(adminview.RoleCreateForm(), s, r, w) + }) +} + +// AdminRoleCreate creates a new role +func AdminRoleCreate(s *hws.Server, conn *bun.DB, audit *auditlog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + + name := getter.String("name").Required().Value + displayName := getter.String("display_name").Required().Value + description := getter.String("description").Value + + if !getter.ValidateAndNotify(s, w, r) { + return + } + + var rolesList []*db.Role + var newRole *db.Role + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + newRole = &db.Role{ + Name: roles.Role(name), + DisplayName: displayName, + Description: description, + IsSystem: false, + CreatedAt: time.Now().Unix(), + } + + err := db.Insert(tx, newRole).WithAudit(r, audit.Callback()).Exec(ctx) + if err != nil { + return false, errors.Wrap(err, "db.Insert") + } + + rolesList, err = db.ListAllRoles(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllRoles") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.RolesList(rolesList), s, r, w) + }) +} + +// AdminRoleManage shows the role management modal with details and actions +func AdminRoleManage(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var role *db.Role + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + role, err = db.GetRoleByID(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleByID") + } + if role == nil { + return false, errors.New("role not found") + } + return true, nil + }); !ok { + return + } + + renderSafely(adminview.RoleManageModal(role), s, r, w) + }) +} + +// AdminRoleDeleteConfirm shows the delete confirmation dialog +func AdminRoleDeleteConfirm(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var role *db.Role + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + role, err = db.GetRoleByID(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleByID") + } + if role == nil { + return false, errors.New("role not found") + } + return true, nil + }); !ok { + return + } + + renderSafely(adminview.ConfirmDeleteRole(roleID, role.DisplayName), s, r, w) + }) +} + +// AdminRoleDelete deletes a role +func AdminRoleDelete(s *hws.Server, conn *bun.DB, audit *auditlog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var rolesList []*db.Role + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + // First check if role exists and get its details + role, err := db.GetRoleByID(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleByID") + } + if role == nil { + return false, errors.New("role not found") + } + + // Check if it's a system role + if role.IsSystem { + return false, errors.New("cannot delete system roles") + } + + // Delete the role with audit logging + err = db.DeleteByID[db.Role](tx, roleID).WithAudit(r, audit.Callback()).Delete(ctx) + if err != nil { + return false, errors.Wrap(err, "db.DeleteByID") + } + + // Reload roles + rolesList, err = db.ListAllRoles(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllRoles") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.RolesList(rolesList), s, r, w) + }) +} + +// AdminRolePermissionsModal shows the permissions management modal for a role +func AdminRolePermissionsModal(s *hws.Server, conn *bun.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var role *db.Role + var allPermissions []*db.Permission + var groupedPerms []adminview.PermissionsByResource + var rolePermIDs map[int]bool + + if ok := db.WithNotifyTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Load role with permissions + var err error + role, err = db.GetRoleWithPermissions(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleWithPermissions") + } + if role == nil { + return false, errors.New("role not found") + } + + // Load all permissions + allPermissions, err = db.ListAllPermissions(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllPermissions") + } + return true, nil + }); !ok { + return + } + + // Group permissions by resource + permsByResource := make(map[string][]*db.Permission) + for _, perm := range allPermissions { + permsByResource[perm.Resource] = append(permsByResource[perm.Resource], perm) + } + + // Convert to sorted slice + for resource, perms := range permsByResource { + groupedPerms = append(groupedPerms, adminview.PermissionsByResource{ + Resource: resource, + Permissions: perms, + }) + } + sort.Slice(groupedPerms, func(i, j int) bool { + return groupedPerms[i].Resource < groupedPerms[j].Resource + }) + + // Create map of current role permissions for checkbox state + rolePermIDs = make(map[int]bool) + for _, perm := range role.Permissions { + rolePermIDs[perm.ID] = true + } + + renderSafely(adminview.RolePermissionsModal(role, groupedPerms, rolePermIDs), s, r, w) + }) +} + +// AdminRolePermissionsUpdate updates the permissions for a role +func AdminRolePermissionsUpdate(s *hws.Server, conn *bun.DB, audit *auditlog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + roleIDStr := r.PathValue("id") + roleID, err := strconv.Atoi(roleIDStr) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + user := db.CurrentUser(r.Context()) + + getter, ok := validation.ParseFormOrNotify(s, w, r) + if !ok { + return + } + + // Get selected permission IDs from form + permissionIDs := getter.IntList("permission_ids").Values() + if !getter.ValidateAndNotify(s, w, r) { + return + } + + selectedPermIDs := make(map[int]bool) + for _, id := range permissionIDs { + selectedPermIDs[id] = true + } + + var rolesList []*db.Role + if ok := db.WithWriteTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + // Get role with current permissions + role, err := db.GetRoleWithPermissions(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleWithPermissions") + } + if role == nil { + throw.NotFound(s, w, r, "Role not found") + return false, nil + } + + // Get all permissions to know what exists + allPermissions, err := db.ListAllPermissions(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllPermissions") + } + + // Build map of current permissions + currentPermIDs := make(map[int]bool) + for _, perm := range role.Permissions { + currentPermIDs[perm.ID] = true + } + + var addedPerms []string + var removedPerms []string + + // Determine what to add and remove + for _, perm := range allPermissions { + hasNow := currentPermIDs[perm.ID] + shouldHave := selectedPermIDs[perm.ID] + + if shouldHave && !hasNow { + // Add permission + err := db.AddPermissionToRole(ctx, tx, roleID, perm.ID) + if err != nil { + return false, errors.Wrap(err, "db.AddPermissionToRole") + } + addedPerms = append(addedPerms, string(perm.Name)) + } else if !shouldHave && hasNow { + // Remove permission + err := db.RemovePermissionFromRole(ctx, tx, roleID, perm.ID) + if err != nil { + return false, errors.Wrap(err, "db.RemovePermissionFromRole") + } + removedPerms = append(removedPerms, string(perm.Name)) + } + } + + // Log the permission changes + if len(addedPerms) > 0 || len(removedPerms) > 0 { + details := map[string]any{ + "role_name": string(role.Name), + } + if len(addedPerms) > 0 { + details["added_permissions"] = addedPerms + } + if len(removedPerms) > 0 { + details["removed_permissions"] = removedPerms + } + err = audit.LogSuccess(ctx, tx, user, "update", "role_permissions", roleID, details, r) + if err != nil { + return false, errors.Wrap(err, "audit.LogSuccess") + } + } + + // Reload roles + rolesList, err = db.ListAllRoles(ctx, tx) + if err != nil { + return false, errors.Wrap(err, "db.ListAllRoles") + } + + return true, nil + }); !ok { + return + } + + renderSafely(adminview.RolesList(rolesList), s, r, w) }) } diff --git a/internal/handlers/admin_users.go b/internal/handlers/admin_users.go index f02ccf3..95f3656 100644 --- a/internal/handlers/admin_users.go +++ b/internal/handlers/admin_users.go @@ -25,25 +25,10 @@ func AdminUsersPage(s *hws.Server, conn *bun.DB) http.Handler { }); !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] - if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { - var err error - // Get users with their roles - users, err = db.GetUsersWithRoles(ctx, tx, nil) - if err != nil { - return false, errors.Wrap(err, "db.GetUsersWithRoles") - } - return true, nil - }); !ok { - return + if r.Method == "GET" { + renderSafely(adminview.DashboardPage(users), s, r, w) + } else { + renderSafely(adminview.UserList(users), s, r, w) } - renderSafely(adminview.UserList(users), s, r, w) }) } diff --git a/internal/rbac/cache_middleware.go b/internal/rbac/cache_middleware.go index 148f803..39bb661 100644 --- a/internal/rbac/cache_middleware.go +++ b/internal/rbac/cache_middleware.go @@ -14,7 +14,7 @@ import ( ) // LoadPermissionsMiddleware loads user permissions into context after authentication -// MUST run AFTER auth.Authenticate() middleware +// MUST run AFTER auth.Authenticate() middleware and LoadPreviewRoleMiddleware func (c *Checker) LoadPermissionsMiddleware() hws.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -34,17 +34,38 @@ func (c *Checker) LoadPermissionsMiddleware() hws.Middleware { return } + // Check if we're in preview mode + previewRole := contexts.GetPreviewRole(r.Context()) + var roles_ []*db.Role var perms []*db.Permission if err := db.WithTxFailSilently(r.Context(), c.conn, func(ctx context.Context, tx bun.Tx) error { var err error - roles_, err = user.GetRoles(ctx, tx) - if err != nil { - return errors.Wrap(err, "user.GetRoles") - } - perms, err = user.GetPermissions(ctx, tx) - if err != nil { - return errors.Wrap(err, "user.GetPermissions") + + if previewRole != nil { + // In preview mode: use the preview role instead of user's roles + role, err := db.GetRoleWithPermissions(ctx, tx, previewRole.ID) + if err != nil { + return errors.Wrap(err, "db.GetRoleWithPermissions") + } + if role != nil { + roles_ = []*db.Role{role} + // Convert []Permission to []*Permission + perms = make([]*db.Permission, len(role.Permissions)) + for i := range role.Permissions { + perms[i] = &role.Permissions[i] + } + } + } else { + // Normal mode: use user's actual roles and permissions + roles_, err = user.GetRoles(ctx, tx) + if err != nil { + return errors.Wrap(err, "user.GetRoles") + } + perms, err = user.GetPermissions(ctx, tx) + if err != nil { + return errors.Wrap(err, "user.GetPermissions") + } } return nil }); err != nil { diff --git a/internal/rbac/checker.go b/internal/rbac/checker.go index fccbbf1..3b81876 100644 --- a/internal/rbac/checker.go +++ b/internal/rbac/checker.go @@ -33,6 +33,9 @@ func (c *Checker) UserHasPermission(ctx context.Context, user *db.User, permissi return false, nil } + // Check if we're in preview mode + previewRole := contexts.GetPreviewRole(ctx) + // Try cache first cache := contexts.Permissions(ctx) if cache != nil { @@ -44,7 +47,14 @@ func (c *Checker) UserHasPermission(ctx context.Context, user *db.User, permissi } } - // Fallback to database + // If in preview mode, DO NOT fallback to database - use ONLY preview role permissions + // This ensures admins cannot bypass preview mode restrictions + if previewRole != nil { + // Not in cache and in preview mode = permission denied + return false, nil + } + + // Not in preview mode: fallback to database for actual user permissions var has bool if err := db.WithTxFailSilently(ctx, c.conn, func(ctx context.Context, tx bun.Tx) error { var err error @@ -65,6 +75,9 @@ func (c *Checker) UserHasRole(ctx context.Context, user *db.User, role roles.Rol return false, nil } + // Check if we're in preview mode + previewRole := contexts.GetPreviewRole(ctx) + cache := contexts.Permissions(ctx) if cache != nil { if has, exists := cache.Roles[role]; exists { @@ -72,13 +85,20 @@ func (c *Checker) UserHasRole(ctx context.Context, user *db.User, role roles.Rol } } - // Fallback to database + // If in preview mode, DO NOT fallback to database - use ONLY preview role + // This ensures admins cannot bypass preview mode restrictions + if previewRole != nil { + // Not in cache and in preview mode = role not assigned + return false, nil + } + + // Not in preview mode: fallback to database for actual user roles var has bool if err := db.WithTxFailSilently(ctx, c.conn, func(ctx context.Context, tx bun.Tx) error { var err error has, err = user.HasRole(ctx, tx, role) if err != nil { - return errors.Wrap(err, "user.HasPermission") + return errors.Wrap(err, "user.HasRole") } return nil }); err != nil { diff --git a/internal/rbac/preview_middleware.go b/internal/rbac/preview_middleware.go new file mode 100644 index 0000000..87b58a1 --- /dev/null +++ b/internal/rbac/preview_middleware.go @@ -0,0 +1,96 @@ +package rbac + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/contexts" + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +// LoadPreviewRoleMiddleware loads the preview role from the session cookie if present +// and adds it to the request context. This must run after authentication but before +// the RBAC cache middleware. +func LoadPreviewRoleMiddleware(s *hws.Server, conn *bun.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if there's a preview role in the cookie + roleID := getPreviewRoleCookie(r) + if roleID == 0 { + // No preview role, continue normally + next.ServeHTTP(w, r) + return + } + + // Load the preview role from the database + var previewRole *db.Role + if ok := db.WithReadTx(s, w, r, conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + previewRole, err = db.GetRoleByID(ctx, tx, roleID) + if err != nil { + return false, errors.Wrap(err, "db.GetRoleByID") + } + if previewRole == nil { + // Role doesn't exist anymore, clear the cookie + ClearPreviewRoleCookie(w) + return true, nil + } + return true, nil + }); !ok { + return + } + + // If role was found, add it to context + if previewRole != nil { + ctx := contexts.WithPreviewRole(r.Context(), previewRole) + r = r.WithContext(ctx) + } + + next.ServeHTTP(w, r) + }) + } +} + +// SetPreviewRoleCookie sets the preview role ID in a session cookie +func SetPreviewRoleCookie(w http.ResponseWriter, roleID int, ssl bool) { + http.SetCookie(w, &http.Cookie{ + Name: "preview_role", + Value: strconv.Itoa(roleID), + Path: "/", + MaxAge: 0, // Session cookie - expires when browser closes or session times out + HttpOnly: true, + Secure: ssl, + SameSite: http.SameSiteLaxMode, + }) +} + +// getPreviewRoleCookie retrieves the preview role ID from the cookie +// Returns 0 if not present or invalid +func getPreviewRoleCookie(r *http.Request) int { + if r == nil { + return 0 + } + cookie, err := r.Cookie("preview_role") + if err != nil { + return 0 + } + roleID, err := strconv.Atoi(cookie.Value) + if err != nil { + return 0 + } + return roleID +} + +// ClearPreviewRoleCookie removes the preview role cookie +func ClearPreviewRoleCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "preview_role", + Value: "", + Path: "/", + MaxAge: -1, + }) +} diff --git a/internal/rbac/protection_middleware.go b/internal/rbac/protection_middleware.go index 3280985..ab695c4 100644 --- a/internal/rbac/protection_middleware.go +++ b/internal/rbac/protection_middleware.go @@ -1,6 +1,7 @@ package rbac import ( + "context" "net/http" "git.haelnorr.com/h/golib/cookies" @@ -10,6 +11,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/roles" "git.haelnorr.com/h/oslstats/internal/throw" "github.com/pkg/errors" + "github.com/uptrace/bun" ) // RequirePermission creates middleware that requires a specific permission @@ -72,3 +74,39 @@ func (c *Checker) RequireRole(s *hws.Server, role roles.Role) func(http.Handler) func (c *Checker) RequireAdmin(server *hws.Server) func(http.Handler) http.Handler { return c.RequireRole(server, roles.Admin) } + +// RequireActualAdmin checks if the user's ACTUAL role is admin, ignoring preview mode +// This is used for critical operations like stopping preview mode +func (c *Checker) RequireActualAdmin(s *hws.Server) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := db.CurrentUser(r.Context()) + if user == nil { + // Not logged in - redirect to login + cookies.SetPageFrom(w, r, r.URL.Path) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Check user's ACTUAL role in database, bypassing preview mode + var hasAdmin bool + if ok := db.WithReadTx(s, w, r, c.conn, func(ctx context.Context, tx bun.Tx) (bool, error) { + var err error + hasAdmin, err = user.HasRole(ctx, tx, roles.Admin) + if err != nil { + return false, errors.Wrap(err, "user.HasRole") + } + return true, nil + }); !ok { + return + } + + if !hasAdmin { + throw.Forbidden(s, w, r, "You don't have the required role to access this resource", errors.New("missing admin role")) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/validation/forms.go b/internal/validation/forms.go index 53de7db..26120d8 100644 --- a/internal/validation/forms.go +++ b/internal/validation/forms.go @@ -24,6 +24,10 @@ func (f *FormGetter) Get(key string) string { return f.r.FormValue(key) } +func (f *FormGetter) GetList(key string) []string { + return f.r.Form[key] +} + func (f *FormGetter) getChecks() []*ValidationRule { return f.checks } @@ -48,6 +52,14 @@ func (f *FormGetter) Time(key string, format *timefmt.Format) *TimeField { return newTimeField(key, format, f) } +func (f *FormGetter) StringList(key string) *StringList { + return newStringList(key, f) +} + +func (f *FormGetter) IntList(key string) *IntList { + return newIntList(key, f) +} + func ParseForm(r *http.Request) (*FormGetter, error) { err := r.ParseForm() if err != nil { diff --git a/internal/validation/integerfield.go b/internal/validation/integerfield.go index 25cc69a..f47d46b 100644 --- a/internal/validation/integerfield.go +++ b/internal/validation/integerfield.go @@ -6,7 +6,7 @@ import ( ) type IntField struct { - Field + FieldBase Value int } @@ -24,8 +24,8 @@ func newIntField(key string, g Getter) *IntField { } } return &IntField{ - Value: val, - Field: newField(key, g), + Value: val, + FieldBase: newField(key, g), } } diff --git a/internal/validation/intlist.go b/internal/validation/intlist.go new file mode 100644 index 0000000..f08a270 --- /dev/null +++ b/internal/validation/intlist.go @@ -0,0 +1,105 @@ +package validation + +import ( + "fmt" + "strconv" +) + +// IntList represents a list of IntFields for validating multiple integer values +type IntList struct { + FieldBase + Fields []*IntField +} + +// newIntList creates a new IntList from form/query values +func newIntList(key string, g Getter) *IntList { + items := g.GetList(key) + list := &IntList{ + FieldBase: newField(key, g), + Fields: make([]*IntField, 0, len(items)), + } + + for _, item := range items { + if item == "" { + continue // Skip empty values + } + + var val int + var err error + val, err = strconv.Atoi(item) + if err != nil { + g.AddCheck(newFailedCheck( + "Value is not a number", + fmt.Sprintf("%s contains invalid integer: %s", key, item), + )) + continue + } + + // Create an IntField directly with the value + field := &IntField{ + Value: val, + FieldBase: newField(key, g), + } + list.Fields = append(list.Fields, field) + } + + return list +} + +// Values returns all int values in the list +func (l *IntList) Values() []int { + values := make([]int, len(l.Fields)) + for i, field := range l.Fields { + values[i] = field.Value + } + return values +} + +// Required enforces at least one value in the list +func (l *IntList) Required() *IntList { + if len(l.Fields) == 0 { + l.getter.AddCheck(newFailedCheck( + "Field not provided", + fmt.Sprintf("%s is required", l.Key), + )) + } + return l +} + +// MinItems enforces a minimum number of items in the list +func (l *IntList) MinItems(min int) *IntList { + if len(l.Fields) < min { + l.getter.AddCheck(newFailedCheck( + "Too few items", + fmt.Sprintf("%s requires at least %d item(s)", l.Key, min), + )) + } + return l +} + +// MaxItems enforces a maximum number of items in the list +func (l *IntList) MaxItems(max int) *IntList { + if len(l.Fields) > max { + l.getter.AddCheck(newFailedCheck( + "Too many items", + fmt.Sprintf("%s allows at most %d item(s)", l.Key, max), + )) + } + return l +} + +// Max enforces a maximum value for each item +func (l *IntList) Max(max int) *IntList { + for _, field := range l.Fields { + field.Max(max) + } + return l +} + +// Min enforces a minimum value for each item +func (l *IntList) Min(min int) *IntList { + for _, field := range l.Fields { + field.Min(min) + } + return l +} diff --git a/internal/validation/querys.go b/internal/validation/querys.go index ed32fd9..36ae7ab 100644 --- a/internal/validation/querys.go +++ b/internal/validation/querys.go @@ -2,6 +2,7 @@ package validation import ( "net/http" + "strings" "git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/timefmt" @@ -21,6 +22,10 @@ func (q *QueryGetter) Get(key string) string { return q.r.URL.Query().Get(key) } +func (q *QueryGetter) GetList(key string) []string { + return strings.Split(q.Get(key), ",") +} + func (q *QueryGetter) getChecks() []*ValidationRule { return q.checks } @@ -45,6 +50,14 @@ func (q *QueryGetter) Time(key string, format *timefmt.Format) *TimeField { return newTimeField(key, format, q) } +func (q *QueryGetter) StringList(key string) *StringList { + return newStringList(key, q) +} + +func (q *QueryGetter) IntList(key string) *IntList { + return newIntList(key, q) +} + func (q *QueryGetter) Validate() bool { return len(validate(q)) == 0 } diff --git a/internal/validation/stringfield.go b/internal/validation/stringfield.go index 00c07db..c864fab 100644 --- a/internal/validation/stringfield.go +++ b/internal/validation/stringfield.go @@ -8,14 +8,14 @@ import ( ) type StringField struct { - Field + FieldBase Value string } func newStringField(key string, g Getter) *StringField { return &StringField{ - Value: g.Get(key), - Field: newField(key, g), + Value: g.Get(key), + FieldBase: newField(key, g), } } diff --git a/internal/validation/stringlist.go b/internal/validation/stringlist.go new file mode 100644 index 0000000..1653a0e --- /dev/null +++ b/internal/validation/stringlist.go @@ -0,0 +1,124 @@ +package validation + +import ( + "fmt" +) + +// StringList represents a list of StringFields for validating multiple string values +type StringList struct { + FieldBase + Fields []*StringField +} + +// newStringList creates a new StringList from form/query values +func newStringList(key string, g Getter) *StringList { + items := g.GetList(key) + list := &StringList{ + FieldBase: newField(key, g), + Fields: make([]*StringField, 0, len(items)), + } + + for _, item := range items { + if item == "" { + continue // Skip empty values + } + // Create a StringField directly with the value + field := &StringField{ + Value: item, + FieldBase: newField(key, g), + } + list.Fields = append(list.Fields, field) + } + + return list +} + +// Values returns all string values in the list +func (l *StringList) Values() []string { + values := make([]string, len(l.Fields)) + for i, field := range l.Fields { + values[i] = field.Value + } + return values +} + +// Required enforces at least one non-empty value in the list +func (l *StringList) Required() *StringList { + if len(l.Fields) == 0 { + l.getter.AddCheck(newFailedCheck( + "Field not provided", + fmt.Sprintf("%s is required", l.Key), + )) + } + return l +} + +// MinItems enforces a minimum number of items in the list +func (l *StringList) MinItems(min int) *StringList { + if len(l.Fields) < min { + l.getter.AddCheck(newFailedCheck( + "Too few items", + fmt.Sprintf("%s requires at least %d item(s)", l.Key, min), + )) + } + return l +} + +// MaxItems enforces a maximum number of items in the list +func (l *StringList) MaxItems(max int) *StringList { + if len(l.Fields) > max { + l.getter.AddCheck(newFailedCheck( + "Too many items", + fmt.Sprintf("%s allows at most %d item(s)", l.Key, max), + )) + } + return l +} + +// MaxLength enforces a maximum string length for each item +func (l *StringList) MaxLength(length int) *StringList { + for _, field := range l.Fields { + field.MaxLength(length) + } + return l +} + +// MinLength enforces a minimum string length for each item +func (l *StringList) MinLength(length int) *StringList { + for _, field := range l.Fields { + field.MinLength(length) + } + return l +} + +// AllowedValues enforces each item must be in the allowed list +func (l *StringList) AllowedValues(allowed []string) *StringList { + for _, field := range l.Fields { + field.AllowedValues(allowed) + } + return l +} + +// ToUpper transforms all strings to uppercase +func (l *StringList) ToUpper() *StringList { + for _, field := range l.Fields { + field.ToUpper() + } + return l +} + +// ToLower transforms all strings to lowercase +func (l *StringList) ToLower() *StringList { + for _, field := range l.Fields { + field.ToLower() + } + return l +} + +// TrimSpace removes leading and trailing whitespace from all items +func (l *StringList) TrimSpace() *StringList { + for _, field := range l.Fields { + field.TrimSpace() + } + return l +} diff --git a/internal/validation/timefield.go b/internal/validation/timefield.go index 6cad564..135a23a 100644 --- a/internal/validation/timefield.go +++ b/internal/validation/timefield.go @@ -8,7 +8,7 @@ import ( ) type TimeField struct { - Field + FieldBase Value time.Time } @@ -26,8 +26,8 @@ func newTimeField(key string, format *timefmt.Format, g Getter) *TimeField { } } return &TimeField{ - Value: startDate, - Field: newField(key, g), + Value: startDate, + FieldBase: newField(key, g), } } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index a08f00d..ca75646 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -21,10 +21,13 @@ type ValidationRule struct { // Getter abstracts getting values from either form or query type Getter interface { Get(key string) string + GetList(key string) []string AddCheck(check *ValidationRule) String(key string) *StringField Int(key string) *IntField Time(key string, format *timefmt.Format) *TimeField + StringList(key string) *StringList + IntList(key string) *IntList ValidateChecks() []*ValidationRule Validate() bool ValidateAndNotify(s *hws.Server, w http.ResponseWriter, r *http.Request) bool @@ -32,14 +35,14 @@ type Getter interface { getChecks() []*ValidationRule } -type Field struct { +type FieldBase struct { Key string optional bool getter Getter } -func newField(key string, g Getter) Field { - return Field{ +func newField(key string, g Getter) FieldBase { + return FieldBase{ Key: key, getter: g, } diff --git a/internal/view/adminview/confirm_dialog.templ b/internal/view/adminview/confirm_dialog.templ new file mode 100644 index 0000000..37b5fe2 --- /dev/null +++ b/internal/view/adminview/confirm_dialog.templ @@ -0,0 +1,47 @@ +package adminview + +import "fmt" + +templ ConfirmDeleteRole(roleID int, roleName string) { + +
+ Are you sure you want to delete the role { roleName }? + This action cannot be undone and will remove this role from all users who have it assigned. +
+{ string(role.Name) }
++ if role.Description != "" { + { role.Description } + } else { + No description provided + } +
+Administrator Role
++ The administrator role is protected and cannot be modified or deleted. This role has full access to all system features. +
+Role: { role.DisplayName }
+Roles management coming soon...
+ +| Name | +Description | +Type | +Actions | +
|---|---|---|---|
| No roles found | +|||
Loading preview...