admin page updates
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -10,4 +10,5 @@ func (c Key) String() string {
|
||||
var (
|
||||
DevModeKey Key = Key("devmode")
|
||||
PermissionCacheKey Key = Key("permissions")
|
||||
PreviewRoleKey Key = Key("preview-role")
|
||||
)
|
||||
|
||||
25
internal/contexts/preview_role.go
Normal file
25
internal/contexts/preview_role.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
80
internal/handlers/admin_preview_role.go
Normal file
80
internal/handlers/admin_preview_role.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
96
internal/rbac/preview_middleware.go
Normal file
96
internal/rbac/preview_middleware.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
105
internal/validation/intlist.go
Normal file
105
internal/validation/intlist.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
internal/validation/stringlist.go
Normal file
124
internal/validation/stringlist.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
47
internal/view/adminview/confirm_dialog.templ
Normal file
47
internal/view/adminview/confirm_dialog.templ
Normal file
@@ -0,0 +1,47 @@
|
||||
package adminview
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ ConfirmDeleteRole(roleID int, roleName string) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('confirm-dialog').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-md p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-12 h-12 text-red" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-text mb-2">Delete Role</h3>
|
||||
<p class="text-sm text-subtext0 mb-4">
|
||||
Are you sure you want to delete the role <span class="font-semibold text-text">{ roleName }</span>?
|
||||
This action cannot be undone and will remove this role from all users who have it assigned.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('confirm-dialog').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-red text-mantle rounded font-semibold hover:bg-maroon transition"
|
||||
hx-delete={ fmt.Sprintf("/admin/roles/%d", roleID) }
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::before-request="document.getElementById('confirm-dialog').innerHTML = ''; document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Delete Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -5,48 +5,25 @@ import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
templ DashboardLayout(activeSection string) {
|
||||
@baseview.Layout("Admin Dashboard") {
|
||||
<div class="max-w-screen-2xl mx-auto px-2">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside
|
||||
class="w-full md:w-64 flex-shrink-0"
|
||||
x-data="{ mobileOpen: false }"
|
||||
>
|
||||
<!-- Mobile toggle button -->
|
||||
<button
|
||||
@click="mobileOpen = !mobileOpen"
|
||||
class="md:hidden w-full bg-surface0 border border-surface1 rounded-lg px-4 py-3 mb-2 flex items-center justify-between hover:bg-surface1 transition"
|
||||
>
|
||||
<span class="font-semibold text-text">Admin Menu</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="mobileOpen ? 'rotate-180' : ''"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Navigation links -->
|
||||
<nav
|
||||
class="bg-surface0 border border-surface1 rounded-lg p-4"
|
||||
:class="mobileOpen ? 'block' : 'hidden md:block'"
|
||||
@click.away="mobileOpen = false"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-text mb-4 px-2">Admin Dashboard</h2>
|
||||
<ul class="space-y-1">
|
||||
@navItem("users", "Users", activeSection)
|
||||
@navItem("roles", "Roles", activeSection)
|
||||
@navItem("permissions", "Permissions", activeSection)
|
||||
@navItem("audit", "Audit Logs", activeSection)
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- Main content area -->
|
||||
<main class="flex-1 min-w-0" id="admin-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-2xl font-bold text-text mb-4">Admin Dashboard</h1>
|
||||
|
||||
<!-- Single cohesive panel with tabs and content -->
|
||||
<div class="border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@navItem("users", "Users", activeSection)
|
||||
@navItem("roles", "Roles", activeSection)
|
||||
@navItem("audit", "Audit Logs", activeSection)
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="bg-crust p-6" id="admin-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/admin.js"></script>
|
||||
}
|
||||
@@ -55,11 +32,11 @@ templ DashboardLayout(activeSection string) {
|
||||
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"
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
}}
|
||||
<li>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL("/admin/" + section) }
|
||||
hx-post={ "/admin/" + section }
|
||||
@@ -67,7 +44,6 @@ templ navItem(section string, label string, activeSection string) {
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ "/admin/" + section }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
@click="if (window.innerWidth < 768) mobileOpen = false"
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
|
||||
91
internal/view/adminview/role_form.templ
Normal file
91
internal/view/adminview/role_form.templ
Normal file
@@ -0,0 +1,91 @@
|
||||
package adminview
|
||||
|
||||
templ RoleCreateForm() {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-md p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-text">Create New Role</h2>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post="/admin/roles/create"
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="name">
|
||||
Name (lowercase, no spaces)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
pattern="[a-z0-9_-]+"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue"
|
||||
placeholder="e.g., moderator"
|
||||
/>
|
||||
<p class="text-xs text-subtext0 mt-1">Used internally, must be unique</p>
|
||||
</div>
|
||||
<!-- Display Name Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="display_name">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue"
|
||||
placeholder="e.g., Moderator"
|
||||
/>
|
||||
<p class="text-xs text-subtext0 mt-1">Human-readable name shown in UI</p>
|
||||
</div>
|
||||
<!-- Description Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue resize-none"
|
||||
placeholder="Brief description of this role's purpose"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue text-mantle rounded font-semibold hover:bg-sky transition"
|
||||
>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
127
internal/view/adminview/role_manage.templ
Normal file
127
internal/view/adminview/role_manage.templ
Normal file
@@ -0,0 +1,127 @@
|
||||
package adminview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||
)
|
||||
|
||||
templ RoleManageModal(role *db.Role) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-2xl p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-text">{ role.DisplayName }</h2>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Role Details -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Internal Name</label>
|
||||
<p class="text-sm text-text font-mono mt-1">{ string(role.Name) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Type</label>
|
||||
<div class="mt-1">
|
||||
if role.IsSystem {
|
||||
<span class="px-2 py-1 bg-yellow/20 text-yellow rounded text-xs font-semibold">SYSTEM</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 bg-surface2 text-subtext0 rounded text-xs">Custom</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Description</label>
|
||||
<p class="text-sm text-text mt-1">
|
||||
if role.Description != "" {
|
||||
{ role.Description }
|
||||
} else {
|
||||
<span class="text-subtext0 italic">No description provided</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons - Only show for non-admin roles -->
|
||||
if role.Name != roles.Admin {
|
||||
<div class="border-t border-surface1 pt-6">
|
||||
<h3 class="text-sm font-semibold text-subtext0 uppercase tracking-wider mb-4">Actions</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Permissions Button -->
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg hover:bg-sky transition font-semibold flex items-center gap-2"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/permissions", role.ID) }
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
Manage Permissions
|
||||
</button>
|
||||
|
||||
<!-- View as Role Button -->
|
||||
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/roles/%d/preview-start", role.ID)) }>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-green text-mantle rounded-lg hover:bg-teal transition font-semibold flex items-center gap-2"
|
||||
hx-indicator="#loading-indicator"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
View as Role
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Delete Button - Only for custom roles -->
|
||||
if !role.IsSystem {
|
||||
<button
|
||||
class="px-4 py-2 bg-red text-mantle rounded-lg hover:bg-maroon transition font-semibold flex items-center gap-2"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/delete-confirm", role.ID) }
|
||||
hx-target="#confirm-dialog"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Delete Role
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Admin role message -->
|
||||
<div class="border-t border-surface1 pt-6">
|
||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow">Administrator Role</p>
|
||||
<p class="text-xs text-yellow/80 mt-1">
|
||||
The administrator role is protected and cannot be modified or deleted. This role has full access to all system features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
86
internal/view/adminview/role_permissions.templ
Normal file
86
internal/view/adminview/role_permissions.templ
Normal file
@@ -0,0 +1,86 @@
|
||||
package adminview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
)
|
||||
|
||||
type PermissionsByResource struct {
|
||||
Resource string
|
||||
Permissions []*db.Permission
|
||||
}
|
||||
|
||||
templ RolePermissionsModal(role *db.Role, permissionsByResource []PermissionsByResource, rolePermissionIDs map[int]bool) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-3xl p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-text">Manage Permissions</h2>
|
||||
<p class="text-sm text-subtext0">Role: <span class="font-semibold text-text">{ role.DisplayName }</span></p>
|
||||
</div>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/admin/roles/%d/permissions", role.ID) }
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Permissions Grouped by Resource -->
|
||||
for _, group := range permissionsByResource {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold text-text mb-3 capitalize">{ group.Resource }</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
for _, perm := range group.Permissions {
|
||||
<label class="flex items-start space-x-3 p-2 rounded hover:bg-surface0 cursor-pointer transition">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permission_ids"
|
||||
value={ fmt.Sprintf("%d", perm.ID) }
|
||||
checked?={ rolePermissionIDs[perm.ID] }
|
||||
class="mt-1 w-4 h-4 text-blue bg-surface1 border-surface2 rounded focus:ring-blue focus:ring-2"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-text">{ perm.DisplayName }</div>
|
||||
if perm.Description != "" {
|
||||
<div class="text-xs text-subtext0 mt-0.5">{ perm.Description }</div>
|
||||
}
|
||||
<div class="text-xs text-subtext1 font-mono mt-0.5">{ string(perm.Name) }</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-2 border-t border-surface1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue text-mantle rounded font-semibold hover:bg-sky transition"
|
||||
>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,14 +1,82 @@
|
||||
package adminview
|
||||
|
||||
templ RolesList() {
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
)
|
||||
|
||||
templ RolesList(roles []*db.Role) {
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<!-- Header with Create Button -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-text">Role Management</h1>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg font-semibold hover:bg-sky transition"
|
||||
hx-get="/admin/roles/create"
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
+ Create Role
|
||||
</button>
|
||||
</div>
|
||||
<!-- Placeholder content -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">Roles management coming soon...</p>
|
||||
<!-- Roles Table -->
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-surface1">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Description</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Type</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
if len(roles) == 0 {
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-subtext0">No roles found</td>
|
||||
</tr>
|
||||
} else {
|
||||
for _, role := range roles {
|
||||
@roleRow(role)
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Container -->
|
||||
<div id="role-modal"></div>
|
||||
<!-- Confirmation Dialog Container -->
|
||||
<div id="confirm-dialog"></div>
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading-indicator" class="htmx-indicator fixed inset-0 bg-crust/80 flex items-center justify-center z-50">
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue mx-auto mb-4"></div>
|
||||
<p class="text-text font-semibold">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ roleRow(role *db.Role) {
|
||||
<tr class="hover:bg-surface1/50 transition">
|
||||
<td class="px-6 py-4 text-sm text-text font-semibold">{ role.DisplayName }</td>
|
||||
<td class="px-6 py-4 text-sm text-subtext0">{ role.Description }</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
if role.IsSystem {
|
||||
<span class="px-2 py-1 bg-yellow/20 text-yellow rounded text-xs font-semibold">SYSTEM</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 bg-surface2 text-subtext0 rounded text-xs">Custom</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-right">
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg hover:bg-sky transition text-sm font-semibold"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/manage", role.ID) }
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package adminview
|
||||
|
||||
templ RolesPage() {
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
|
||||
templ RolesPage(roles []*db.Role) {
|
||||
@DashboardLayout("roles") {
|
||||
@RolesList()
|
||||
@RolesList(roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package baseview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/popup"
|
||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
|
||||
// Global base layout for all pages
|
||||
templ Layout(title string) {
|
||||
{{ devInfo := contexts.DevMode(ctx) }}
|
||||
{{ previewRole := contexts.GetPreviewRole(ctx) }}
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
@@ -43,6 +45,9 @@ templ Layout(title string) {
|
||||
class="flex flex-col h-screen justify-between"
|
||||
>
|
||||
@Navbar()
|
||||
if previewRole != nil {
|
||||
@previewModeBanner(previewRole)
|
||||
}
|
||||
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
||||
{ children... }
|
||||
</div>
|
||||
@@ -51,3 +56,38 @@ templ Layout(title string) {
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// Preview mode banner (private helper)
|
||||
templ previewModeBanner(previewRole *db.Role) {
|
||||
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
<span class="text-yellow font-semibold">
|
||||
Preview Mode: Viewing as <span class="font-bold">{ previewRole.DisplayName }</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="POST" action="/admin/roles/preview-stop?stay=true">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-1 bg-green text-mantle rounded-lg font-semibold hover:bg-teal transition text-sm"
|
||||
>
|
||||
Stop Preview
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/roles/preview-stop">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-1 bg-blue text-mantle rounded-lg font-semibold hover:bg-sky transition text-sm"
|
||||
>
|
||||
Return to Admin Panel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -25,13 +25,21 @@ func getNavItems() []NavItem {
|
||||
}
|
||||
}
|
||||
|
||||
// Profile dropdown items (context-aware for admin)
|
||||
// Profile dropdown items (context-aware for admin and preview mode)
|
||||
func getProfileItems(ctx context.Context) []ProfileItem {
|
||||
items := []ProfileItem{
|
||||
{Name: "Profile", Href: "/profile"},
|
||||
{Name: "Account", Href: "/account"},
|
||||
}
|
||||
|
||||
// Check if we're in preview mode
|
||||
previewRole := contexts.GetPreviewRole(ctx)
|
||||
if previewRole != nil {
|
||||
// In preview mode: show stop viewing button instead of admin panel
|
||||
return items
|
||||
}
|
||||
|
||||
// Not in preview mode: show admin panel if user is admin
|
||||
cache := contexts.Permissions(ctx)
|
||||
if cache != nil && cache.Roles["admin"] {
|
||||
items = append(items, ProfileItem{
|
||||
@@ -104,6 +112,7 @@ templ userMenu(user *db.User, profileItems []ProfileItem) {
|
||||
|
||||
// Profile dropdown (private helper)
|
||||
templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
{{ previewRole := contexts.GetPreviewRole(ctx) }}
|
||||
<div x-data="{ isActive: false }" class="relative">
|
||||
<div
|
||||
class="inline-flex items-center overflow-hidden rounded-lg
|
||||
@@ -118,7 +127,7 @@ templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute end-0 z-10 mt-2 w-36 divide-y divide-surface2
|
||||
class="absolute end-0 z-10 mt-2 w-48 divide-y divide-surface2
|
||||
rounded-lg border border-surface1 bg-surface0 shadow-lg"
|
||||
role="menu"
|
||||
x-cloak
|
||||
@@ -127,6 +136,38 @@ templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
x-on:click.away="isActive = false"
|
||||
x-on:keydown.escape.window="isActive = false"
|
||||
>
|
||||
<!-- Preview Mode Stop Buttons -->
|
||||
if previewRole != nil {
|
||||
<div class="p-2 bg-yellow/10 border-b border-yellow/30 space-y-2">
|
||||
<p class="text-xs text-yellow/80 px-2 font-semibold">
|
||||
Viewing as: { previewRole.DisplayName }
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="/admin/roles/preview-stop?stay=true" class="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg px-3 py-2
|
||||
text-sm text-mantle bg-green font-semibold hover:bg-teal hover:cursor-pointer transition"
|
||||
role="menuitem"
|
||||
@click="isActive=false"
|
||||
>
|
||||
Stop Preview
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/roles/preview-stop" class="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg px-3 py-2
|
||||
text-sm text-mantle bg-blue font-semibold hover:bg-sky hover:cursor-pointer transition"
|
||||
role="menuitem"
|
||||
@click="isActive=false"
|
||||
>
|
||||
Return to Admin
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Profile links -->
|
||||
<div class="p-2">
|
||||
for _, item := range items {
|
||||
|
||||
4
justfile
4
justfile
@@ -114,8 +114,8 @@ _migrate-status:
|
||||
{{bin}}/{{entrypoint}} --migrate-status --envfile $ENVFILE
|
||||
|
||||
[private]
|
||||
_migrate-new name:
|
||||
echo "Creating new migration {{name}}"
|
||||
_migrate-new name: && _migrate-status
|
||||
{{bin}}/{{entrypoint}} --migrate-create {{name}}
|
||||
|
||||
# Hard reset the database
|
||||
[group('db')]
|
||||
|
||||
Reference in New Issue
Block a user