Benutzerverwaltung

This commit is contained in:
Simon Martens
2025-05-26 17:27:52 +02:00
parent d1ab30e387
commit 24b56ff97f
8 changed files with 424 additions and 98 deletions

View File

@@ -0,0 +1,24 @@
package middleware
import (
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
)
// INFO: Here the URL must have a path value "uid" which is the user ID of the affected user.
func IsAdminOrUser() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
req := templating.NewRequest(e)
user := req.User()
if user == nil {
return e.Error(401, "Unauthorized", nil)
}
uid := e.Request.PathValue("uid")
if uid != user.Id && user.Role != "Admin" {
return e.Error(403, "Forbidden", nil)
}
return e.Next()
}
}

View File

@@ -14,9 +14,13 @@ import (
)
const (
URL_USER_EDIT = "/user/{uid}/edit/"
UID_PATH_VALUE = "uid"
TEMPLATE_USER_EDIT = "/user/edit/"
URL_USER = "/user/{uid}/"
URL_USER_EDIT = "edit/"
URL_USER_LOGOUT = "logout/"
URL_USER_DEACTIVATE = "deactivate/"
URL_USER_ACTIVATE = "activate/"
UID_PATH_VALUE = "uid"
TEMPLATE_USER_EDIT = "/user/edit/"
)
func init() {
@@ -36,8 +40,12 @@ type UserEditPage struct {
}
func (p *UserEditPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
router.GET(URL_USER_EDIT, p.GET(engine, app))
router.POST(URL_USER_EDIT, p.POST(engine, app))
rg := router.Group(URL_USER)
rg.BindFunc(middleware.IsAdminOrUser())
rg.GET(URL_USER_EDIT, p.GET(engine, app))
rg.POST(URL_USER_EDIT, p.POST(engine, app))
rg.POST(URL_USER_DEACTIVATE, p.POSTDeactivate(engine, app))
rg.POST(URL_USER_ACTIVATE, p.POSTActivate(engine, app))
return nil
}
@@ -46,16 +54,6 @@ func (p *UserEditPage) GET(engine *templating.Engine, app core.App) HandleFunc {
data := make(map[string]any)
uid := e.Request.PathValue(UID_PATH_VALUE)
req := templating.NewRequest(e)
user := req.User()
if user == nil {
return engine.Response404(e, nil, nil)
}
if user.Id != uid && user.Role != "Admin" {
return engine.Response404(e, nil, nil)
}
u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid)
if err != nil {
@@ -105,7 +103,7 @@ func DeleteSessionsForUser(app core.App, uid string) error {
return nil
}
func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error string, user *dbmodels.FixedUser) error {
func (p *UserEditPage) InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error string, user *dbmodels.FixedUser) error {
data := make(map[string]any)
data["error"] = error
data["user"] = user
@@ -118,9 +116,7 @@ func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error
data["csrf_nonce"] = nonce
data["csrf_token"] = token
SetRedirect(data, e)
str, err := engine.RenderToString(e, data, TEMPLATE_USER_EDIT, "blank")
str, err := engine.RenderToString(e, data, p.Template, p.Layout)
if err != nil {
return engine.Response500(e, err, data)
}
@@ -128,6 +124,98 @@ func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error
return e.HTML(400, str)
}
func (p *UserEditPage) POSTDeactivate(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
uid := e.Request.PathValue(UID_PATH_VALUE)
req := templating.NewRequest(e)
user := req.User()
if user == nil {
return engine.Response404(e, nil, nil)
}
if user.Id != uid && user.Role != "Admin" {
return engine.Response404(e, nil, nil)
}
u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid)
if err != nil {
return engine.Response404(e, err, nil)
}
user_proxy := dbmodels.NewUser(u)
user_proxy.SetDeactivated(true)
if err := app.Save(user_proxy); err != nil {
return engine.Response500(e, err, nil)
}
go middleware.SESSION_CACHE.DeleteSessionByUserID(user_proxy.Id)
if user_proxy.Id == user.Id {
// INFO: user deactivated his own account, so we log him out
return e.Redirect(303, "/login/")
}
data := make(map[string]any)
uf := user_proxy.Fixed()
data["user"] = &uf
data["success"] = "Benutzer " + uf.Name + " deaktiviert."
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_token"] = token
data["csrf_nonce"] = nonce
return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout)
}
}
func (p *UserEditPage) POSTActivate(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
uid := e.Request.PathValue(UID_PATH_VALUE)
req := templating.NewRequest(e)
user := req.User()
u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid)
if err != nil {
return engine.Response404(e, err, nil)
}
user_proxy := dbmodels.NewUser(u)
user_proxy.SetDeactivated(false)
if err := app.Save(user_proxy); err != nil {
return engine.Response500(e, err, nil)
}
go middleware.SESSION_CACHE.DeleteSessionByUserID(user_proxy.Id)
if user_proxy.Id == user.Id {
// INFO: user deactivated his own account, so we log him out
return e.Redirect(303, "/login/")
}
data := make(map[string]any)
uf := user_proxy.Fixed()
data["user"] = &uf
data["success"] = "Benutzer " + uf.Name + " aktiviert."
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_token"] = token
data["csrf_nonce"] = nonce
return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout)
}
}
func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
data := make(map[string]any)
@@ -164,19 +252,19 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc
}{}
if err := e.BindBody(&formdata); err != nil {
return InvalidDataResponse(engine, e, err.Error(), &fu)
return p.InvalidDataResponse(engine, e, err.Error(), &fu)
}
if formdata.CsrfNonce != "" && formdata.CsrfToken != "" {
if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil {
return InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu)
return p.InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu)
}
} else {
return InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu)
return p.InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu)
}
if formdata.Email == "" || formdata.Name == "" {
return InvalidDataResponse(engine, e, "Bitte alle Felder ausfüllen", &fu)
return p.InvalidDataResponse(engine, e, "Bitte alle Felder ausfüllen", &fu)
}
// INFO: at this point email and name changes are allowed
@@ -190,20 +278,20 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc
user_proxy.SetRole(formdata.Role)
rolechanged = true
} else {
return InvalidDataResponse(engine, e, "Rolle nicht erlaubt", &fu)
return p.InvalidDataResponse(engine, e, "Rolle nicht erlaubt", &fu)
}
}
passwordchanged := false
if formdata.Password != "" || formdata.PasswordRepeat != "" || formdata.OldPassword != "" {
if user.Role != "Admin" && formdata.OldPassword == "" {
return InvalidDataResponse(engine, e, "Altes Passwort erforderlich", &fu)
return p.InvalidDataResponse(engine, e, "Altes Passwort erforderlich", &fu)
} else if user.Role != "Admin" && !user_proxy.ValidatePassword(formdata.OldPassword) {
return InvalidDataResponse(engine, e, "Altes Passwort falsch", &fu)
return p.InvalidDataResponse(engine, e, "Altes Passwort falsch", &fu)
}
if formdata.Password != formdata.PasswordRepeat {
return InvalidDataResponse(engine, e, "Passwörter stimmen nicht überein", &fu)
return p.InvalidDataResponse(engine, e, "Passwörter stimmen nicht überein", &fu)
}
user_proxy.SetPassword(formdata.Password)
@@ -211,14 +299,14 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc
}
if err := app.Save(user_proxy); err != nil {
return InvalidDataResponse(engine, e, err.Error(), &fu)
return p.InvalidDataResponse(engine, e, err.Error(), &fu)
}
slog.Info("UserEditPage: User edited", "user_id", user_proxy.Id, "role_changed", rolechanged, "password_changed", passwordchanged, "formdata", formdata)
if rolechanged || (passwordchanged && formdata.Logout == "on") {
slog.Error("UserEditPage: Deleting sessions for user", "user_id", user_proxy.Id, "role_changed", rolechanged, "password_changed", passwordchanged)
if err := DeleteSessionsForUser(app, user_proxy.Id); err != nil {
return InvalidDataResponse(engine, e, "Fehler beim Löschen der Sitzungen: "+err.Error(), &fu)
return p.InvalidDataResponse(engine, e, "Fehler beim Löschen der Sitzungen: "+err.Error(), &fu)
}
if user_proxy.Id == user.Id {
@@ -244,7 +332,6 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc
data["csrf_token"] = token
data["csrf_nonce"] = nonce
SetRedirect(data, e)
return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout)
}
}

View File

@@ -15,6 +15,9 @@ import (
const (
URL_USER_MANAGEMENT = "/user/management"
TEMPLATE_USER_MANAGEMENT = "/user/management/"
URL_DEACTIVATE_USER = "/deactivate/"
URL_ACTIVATE_USER = "/activate/"
URL_LOGOUT_USER = "/logout/"
)
type SessionCount struct {
@@ -42,7 +45,9 @@ func (p *UserManagementPage) Setup(router *router.Router[*core.RequestEvent], ap
rg := router.Group(URL_USER_MANAGEMENT)
rg.BindFunc(middleware.IsAdmin())
rg.GET("", p.GET(engine, app))
rg.POST("", p.POST(engine, app))
rg.POST(URL_DEACTIVATE_USER, p.POSTDeactivate(engine, app))
rg.POST(URL_ACTIVATE_USER, p.POSTActivate(engine, app))
rg.POST(URL_LOGOUT_USER, p.POSTLogout(engine, app))
return nil
}
@@ -63,47 +68,125 @@ func GetSessionsCounts(app core.App) ([]*SessionCount, error) {
func (p *UserManagementPage) GET(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
records := []*core.Record{}
err := app.RecordQuery(dbmodels.USERS_TABLE).OrderBy(dbmodels.USERS_NAME_FIELD).All(&records)
if err != nil {
return engine.Response500(e, err, nil)
}
users := make([]*dbmodels.User, 0, len(records))
for _, record := range records {
users = append(users, dbmodels.NewUser(record))
}
sessionCounts, err := GetSessionsCounts(app)
if err != nil {
return engine.Response500(e, err, nil)
}
scmap := make(map[string]int)
for _, sc := range sessionCounts {
scmap[sc.UserId] = sc.Count
}
data := make(map[string]any)
data["users"] = users
data["len"] = len(users)
data["session_counts"] = scmap
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
p.getData(app, data)
SetRedirect(data, e)
return engine.Response200(e, p.Template, data, p.Layout)
}
}
func (p *UserManagementPage) POST(engine *templating.Engine, app core.App) HandleFunc {
func (p *UserManagementPage) getData(app core.App, data map[string]any) error {
records := []*core.Record{}
err := app.RecordQuery(dbmodels.USERS_TABLE).OrderBy(dbmodels.USERS_NAME_FIELD).All(&records)
if err != nil {
return fmt.Errorf("Konnte keine Nutzer laden: %w", err)
}
users := make([]*dbmodels.User, 0, len(records))
for _, record := range records {
users = append(users, dbmodels.NewUser(record))
}
sessionCounts, err := GetSessionsCounts(app)
if err != nil {
return fmt.Errorf("Konnte keine Sitzungsanzahlen laden: %w", err)
}
scmap := make(map[string]int)
for _, sc := range sessionCounts {
scmap[sc.UserId] = sc.Count
}
data["users"] = users
data["len"] = len(users)
data["session_counts"] = scmap
csrfNonce, csrfToken, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return fmt.Errorf("Konnte kein CSRF-Token generieren", err)
}
data["csrf_nonce"] = csrfNonce
data["csrf_token"] = csrfToken
return nil
}
func (p *UserManagementPage) ErrorResponse(engine *templating.Engine, e *core.RequestEvent, err error) error {
data := make(map[string]any)
data["error"] = err.Error()
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
SetRedirect(data, e)
str, err := engine.RenderToString(e, data, p.Template, p.Layout)
return e.HTML(400, str)
}
func (p *UserManagementPage) POSTDeactivate(engine *templating.Engine, app core.App) HandleFunc {
return p.UserAction(engine, app, func(user *dbmodels.User) {
user.SetDeactivated(true)
})
}
func (p *UserManagementPage) POSTActivate(engine *templating.Engine, app core.App) HandleFunc {
return p.UserAction(engine, app, func(user *dbmodels.User) {
user.SetDeactivated(false)
})
}
func (p *UserManagementPage) POSTLogout(engine *templating.Engine, app core.App) HandleFunc {
return p.UserAction(engine, app, func(user *dbmodels.User) {})
}
func (p *UserManagementPage) UserAction(engine *templating.Engine, app core.App, fn func(user *dbmodels.User)) HandleFunc {
return func(e *core.RequestEvent) error {
return fmt.Errorf("not implemented")
formdata := struct {
User string `form:"uid"`
CSRF string `form:"csrf_token"`
Nonce string `form:"csrf_nonce"`
}{}
if err := e.BindBody(&formdata); err != nil {
return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Formular nicht binden: %w", err))
}
if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.Nonce, formdata.CSRF); err != nil {
return p.ErrorResponse(engine, e, err)
}
user, err := app.FindRecordById(dbmodels.USERS_TABLE, formdata.User)
if err != nil {
return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Nutzer nicht finden."))
}
u := dbmodels.NewUser(user)
fn(u)
if err := app.Save(u); err != nil {
return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Nutzer nicht deaktivieren: %w", err))
}
go DeleteSessionsForUser(app, u.Id)
data := make(map[string]any)
data["success"] = "Nutzer " + u.Name() + "(" + u.Email() + ") wurde deaktiviert."
p.getData(app, data)
req := templating.NewRequest(e)
if req.User() != nil && req.User().Id == u.Id {
return e.Redirect(303, "/login/")
}
return engine.Response200(e, p.Template, data, p.Layout)
}
}

103
templating/templates.go Normal file
View File

@@ -0,0 +1,103 @@
package templating
import (
"io/fs"
"log/slog"
"path/filepath"
"slices"
"strings"
"sync"
)
// TODO: redo template parsing to allow for just rendering single components
type File struct {
Name string
Path string
Ext string
}
func FromDirEntry(entry fs.DirEntry, path string) File {
ext := filepath.Ext(entry.Name())
Name := strings.TrimSuffix(entry.Name(), ext)
return File{
Name: Name,
Ext: ext,
Path: path,
}
}
type Directory struct {
Path string
Locals map[string]File
Globals map[string]File
}
type Templates struct {
Directories sync.Map // map[string]*Directory
}
func (t *Templates) NewDirectory(path string, fsys fs.FS) (*Directory, error) {
fspath := PathToFSPath(path)
entries, err := fs.ReadDir(fsys, fspath)
wg := &sync.WaitGroup{}
if err != nil {
return nil, NewError(FileAccessError, fspath)
}
dir := &Directory{}
for _, file := range entries {
if file.IsDir() && file.Name() == TEMPLATE_COMPONENT_DIRECTORY {
wg.Add(1)
go func() {
defer wg.Done()
locals, globals, err := t.ScanComponentDirectory(filepath.Join(fspath, file.Name()), fsys)
if err != nil {
slog.Error("Failed to scan component directory", "path", file.Name(), "error", err)
return
}
dir.Locals = locals
dir.Globals = globals
}()
}
if !slices.Contains(TEMPLATE_FORMATS, filepath.Ext(file.Name())) {
continue
}
}
wg.Wait()
return dir, nil
}
func (t *Templates) ScanComponentDirectory(path string, fsys fs.FS) (map[string]File, map[string]File, error) {
locals := make(map[string]File)
globals := make(map[string]File)
fspath := PathToFSPath(path)
err := fs.WalkDir(fsys, fspath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return NewError(FileAccessError, path)
}
if d.IsDir() || !slices.Contains(TEMPLATE_FORMATS, filepath.Ext(d.Name())) {
return nil
}
f := FromDirEntry(d, path)
if strings.HasPrefix(f.Name, TEMPLATE_GLOBAL_PREFIX) {
globals[f.Name] = f
} else {
locals[f.Name] = f
}
return nil
})
if err != nil {
return nil, nil, NewError(FileAccessError, path)
}
return locals, globals, nil
}

View File

@@ -0,0 +1,19 @@
{{ $model := . }}
<div id="user-message">
{{ if $model.success }}
<div
class="text-green-800 text-sm mt-2 rounded-xs bg-green-200 p-2 font-bold border-green-700
shadow border mb-3">
<i class="ri-checkbox-circle-fill"></i> {{ $model.success }}
</div>
{{ end }}
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded-xs bg-red-200 p-2 font-bold border-red-700 shadow
border mb-3">
<i class="ri-error-warning-fill"></i> {{ $model.error }}
</div>
{{ end }}
</div>

View File

@@ -0,0 +1,17 @@
{{ $model := . }}
<div id="user-message">
{{ if $model.success }}
<div
class="text-green-800 text-sm mt-2 rounded bg-green-200 p-2 font-bold border-green-700
border-2 mb-3">
{{ $model.success }}
</div>
{{ end }}
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
border-2 mb-3">
{{ $model.error }}
</div>
{{ end }}
</div>

View File

@@ -17,20 +17,7 @@
</div>
<div class="flex container-normal mx-auto px-8 mt-4">
<div class="flex-col max-w-2xl w-full">
{{ if $model.success }}
<div
class="text-green-800 text-sm mt-2 rounded bg-green-200 p-2 font-bold border-green-700
border-2 mb-3">
{{ $model.success }}
</div>
{{ end }}
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
border-2 mb-3">
{{ $model.error }}
</div>
{{ end }}
{{ template "_usermessage" $model }}
<form class="w-full grid grid-cols-3 gap-4" method="POST" x-data="{ openpw: false }">
<div
class="rounded-xs col-span-3 border-2 border-transparent px-3
@@ -115,8 +102,8 @@
<i class="ri-information-line text-gray-700 mt-2 mr-2 align-top"></i>
</div>
<p class="text-sm text-gray-700 max-w-[80ch]">
Achtung! Wenn Sie die Rolle eines Benutzers ändern, wird dieser von allen laufenden
Sitzungen abgemeldet und muss sich erneut anmelden.
Achtung! Wenn Sie die Rolle eines Benutzers ändern, wird dieser unter Umständen von
laufenden Sitzungen abgemeldet und muss sich erneut anmelden.
</p>
</div>
{{- end -}}

View File

@@ -21,20 +21,22 @@
</div>
<div class="flex container-normal mx-auto px-8 mt-4">
<div class="flex-col w-full">
{{ if $model.success }}
<div
class="text-green-800 text-sm mt-2 rounded bg-green-200 p-2 font-bold border-green-700
<div id="user-message">
{{ if $model.success }}
<div
class="text-green-800 text-sm mt-2 rounded bg-green-200 p-2 font-bold border-green-700
border-2 mb-3">
{{ $model.success }}
</div>
{{ end }}
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
{{ $model.success }}
</div>
{{ end }}
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
border-2 mb-3">
{{ $model.error }}
</div>
{{ end }}
{{ $model.error }}
</div>
{{ end }}
</div>
<table class="user-mgmt w-full text-lg">
<thead>
<tr>
@@ -54,6 +56,7 @@
<td>{{ index $model.session_counts $u.Id }}</td>
<td>
<form class="flex flex-row gap-x-4 justify-end">
<input type="hidden" name="uid" id="uid" required value="{{ $u.Id }}" />
<input
type="hidden"
name="csrf_nonce"
@@ -72,22 +75,25 @@
<i class="ri-pencil-line"></i>
</button>
<button
hx-push-url="false"
formmethod="POST"
formaction="/user/{{ $u.Id }}/logout"
formaction="/user/management/logout/"
class="text-orange-800 bg-orange-200 hover:bg-orange-300">
<i class="ri-logout-box-r-line"></i>
</button>
{{- if $u.Deactivated }}
<button
formmethod="GET"
formaction="/user/{{ $u.Id }}/activate"
hx-push-url="false"
formmethod="POST"
formaction="/user/management/activate/"
class="text-blue-800 bg-blue-200 hover:bg-blue-300">
<i class="ri-check-line"></i>
</button>
{{- else -}}
<button
formmethod="GET"
formaction="/user/{{ $u.Id }}/deactivate"
hx-push-url="false"
formmethod="POST"
formaction="/user/management/deactivate/"
class="text-red-800 bg-red-200 hover:bg-red-300">
<i class="ri-prohibited-2-line"></i>
</button>