Files
Gardomatic/internal/web/admin.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

417 lines
16 KiB
Go

package web
import (
"errors"
"fmt"
"net/http"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type adminRoleForm struct {
Role string `form:"role"`
}
type adminUserInviteForm struct {
Name string `form:"name"`
Email string `form:"email"`
}
type roleSettingsForm struct {
Name string `form:"name"`
Scope string `form:"scope"`
Label string `form:"label"`
Permissions []string `form:"permissions"`
}
func applicationPermissionOptions() []permissionOption {
return []permissionOption{{"gardens:create", "Gärten anlegen"}, {"global_species:write", "Globale Pflanzen verwalten"}, {"users:manage", "Nutzerrollen verwalten"}, {"application_settings:write", "Instanzeinstellungen verwalten"}, {"roles:manage", "Rollen verwalten"}}
}
func gardenPermissionOptions() []permissionOption {
return []permissionOption{
{"garden:read", "Garten sehen"}, {"garden:update", "Garten bearbeiten"}, {"garden:delete", "Garten löschen"}, {"members:write", "Mitglieder verwalten"}, {"species:write", "Pflanzenarten verwalten"}, {"content:write", "Tagebuch und Zuordnungen bearbeiten"},
{"plants:create", "Pflanzen anlegen"}, {"plants:read:own", "Eigene Pflanzen sehen"}, {"plants:read:other", "Andere Pflanzen sehen"}, {"plants:update:own", "Eigene Pflanzen bearbeiten"}, {"plants:update:other", "Andere Pflanzen bearbeiten"}, {"plants:delete:own", "Eigene Pflanzen löschen"}, {"plants:delete:other", "Andere Pflanzen löschen"},
{"locations:create", "Orte anlegen"}, {"locations:read:own", "Eigene Orte sehen"}, {"locations:read:other", "Andere Orte sehen"}, {"locations:update:own", "Eigene Orte bearbeiten"}, {"locations:update:other", "Andere Orte bearbeiten"}, {"locations:delete:own", "Eigene Orte löschen"}, {"locations:delete:other", "Andere Orte löschen"},
{"tasks:create", "Aufgaben anlegen"}, {"tasks:read:own", "Eigene Aufgaben sehen"}, {"tasks:read:other", "Andere Aufgaben sehen"}, {"tasks:update:own", "Eigene Aufgaben bearbeiten"}, {"tasks:update:other", "Andere Aufgaben bearbeiten"}, {"tasks:delete:own", "Eigene Aufgaben löschen"}, {"tasks:delete:other", "Andere Aufgaben löschen"}, {"tasks:complete:own", "Eigene Aufgaben erledigen"}, {"tasks:complete:other", "Andere Aufgaben erledigen"},
}
}
type adminSpeciesCategoryForm struct {
Name string `form:"name"`
SortOrder int `form:"sort_order"`
Active bool `form:"active"`
Lifecycle string `form:"lifecycle"`
}
type adminTaskPriorityForm struct {
Name string `form:"name"`
Value int `form:"value"`
SortOrder int `form:"sort_order"`
Active bool `form:"active"`
}
type adminApplicationSettingsForm struct {
LifecycleStatusEnabled bool `form:"lifecycle_status_enabled"`
LifecycleRemovalMonth int `form:"lifecycle_removal_month"`
LifecycleRemovalDay int `form:"lifecycle_removal_day"`
Timezone string `form:"timezone"`
}
type adminTestMailForm struct {
Email string `form:"email"`
}
func (app *application) requireAdmin(next http.Handler) http.Handler {
return app.requireActivatedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok || !user.IsAdmin() {
app.clientError(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}))
}
func (app *application) requireApplicationPermission(permission string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok || !user.Can(permission) {
app.clientError(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) admin(w http.ResponseWriter, r *http.Request) {
apiClient := client.FromContext(r.Context())
users, _, err := apiClient.AdminUsers(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
categories, _, err := client.FromContext(r.Context()).AdminSpeciesCategories(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
priorities, _, err := client.FromContext(r.Context()).AdminTaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
settings, _, err := client.FromContext(r.Context()).AdminApplicationSettings(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
environmentVariables, _, err := apiClient.AdminEnvironment(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
applicationRoles, gardenRoles, _, err := apiClient.AdminRoles(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
if !app.loadOptionalGarden(w, r, data) {
return
}
data.AdminUsers, data.SpeciesCategories, data.TaskPriorities = users, categories, priorities
data.ApplicationRoles, data.GardenRoles = applicationRoles, gardenRoles
data.ApplicationPermissions, data.GardenPermissions = applicationPermissionOptions(), gardenPermissionOptions()
data.RoleEditors = []roleEditorData{
globalRoleEditor("roles-settings", "Instanzrollen", "Diese Rollen gelten für die gesamte Serverinstanz.", "application", applicationRoles, data.ApplicationPermissions, data.CSRFToken),
globalRoleEditor("garden-role-templates", "Gartenrollen", "Diese Vorlagen gelten in allen Gärten und können je Garten überschrieben werden.", "garden", gardenRoles, data.GardenPermissions, data.CSRFToken),
}
for i := range data.RoleEditors {
data.RoleEditors[i].Garden = data.Garden
}
data.ApplicationSettings = &settings
data.EnvironmentVariables = append(environmentVariables, app.webEnvironmentVariables()...)
if r.URL.Query().Get("test-mail") == "sent" {
data.Flash = "Die Testmail wurde versendet."
}
if r.URL.Query().Get("test-mail") == "invalid" {
data.Flash = "Bitte gib eine gültige Empfängeradresse für die Testmail ein."
}
if r.URL.Query().Get("invitation") == "sent" {
data.Flash = "Die Einladung wurde versendet."
}
if r.URL.Query().Get("invitation") == "duplicate" {
data.Flash = "Für diese E-Mail-Adresse existiert bereits ein aktiver Account."
}
if r.URL.Query().Get("invitation") == "invalid" {
data.Flash = "Die Einladung ist ungültig. Bitte prüfe Name und E-Mail-Adresse."
}
if r.URL.Query().Get("deletion") == "done" {
data.Flash = "Der Nutzer wurde gelöscht und seine Kontodaten wurden anonymisiert."
}
if r.URL.Query().Get("deletion") == "owner" {
data.Flash = "Der Nutzer besitzt noch mindestens einen Garten. Übertrage zuerst das Eigentum."
}
app.render(w, http.StatusOK, "admin.tmpl", data)
}
func (app *application) adminUserInvitePost(w http.ResponseWriter, r *http.Request) {
var form adminUserInviteForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
input := client.AdminUserInviteInput{Name: strings.TrimSpace(form.Name), Email: strings.TrimSpace(form.Email)}
if _, _, err := client.FromContext(r.Context()).InviteAdminUser(r.Context(), input); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
status := "invalid"
if apiError.Validation["email"] == "a user with this email address already exists" {
status = "duplicate"
}
http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", status), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", "sent"), http.StatusSeeOther)
}
func (app *application) adminUserDeletePost(w http.ResponseWriter, r *http.Request) {
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
if _, err = client.FromContext(r.Context()).DeleteAdminUser(r.Context(), userID); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict {
http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "owner"), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "done"), http.StatusSeeOther)
}
func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
return []client.EnvironmentVariable{
{Component: "Web", Name: "GARDOMATIC_ENV", Value: app.config.Env},
{Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
{Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
{Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
}
}
func (app *application) adminRoleCreatePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
_, _, err := client.FromContext(r.Context()).CreateAdminRole(r.Context(), client.RoleInput{Name: strings.TrimSpace(form.Name), Scope: form.Scope, Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func (app *application) adminRoleUpdatePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
_, _, err := client.FromContext(r.Context()).UpdateAdminRole(r.Context(), form.Name, client.RoleInput{Label: strings.TrimSpace(form.Label), Permissions: form.Permissions})
if err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func (app *application) adminRoleDeletePost(w http.ResponseWriter, r *http.Request) {
var form roleSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminRole(r.Context(), form.Name); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther)
}
func adminRoleEditorAnchor(scope string) string {
if scope == "garden" {
return "#garden-role-templates"
}
return "#roles-settings"
}
func (app *application) adminApplicationSettingsPost(w http.ResponseWriter, r *http.Request) {
var form adminApplicationSettingsForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
timezone := strings.TrimSpace(form.Timezone)
input := client.ApplicationSettingsInput{
LifecycleStatusEnabled: &form.LifecycleStatusEnabled,
LifecycleRemovalMonth: &form.LifecycleRemovalMonth,
LifecycleRemovalDay: &form.LifecycleRemovalDay,
Timezone: &timezone,
}
if _, _, err := client.FromContext(r.Context()).UpdateAdminApplicationSettings(r.Context(), input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#lifecycle-settings"), http.StatusSeeOther)
}
func (app *application) adminTestMailPost(w http.ResponseWriter, r *http.Request) {
var form adminTestMailForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, err := client.FromContext(r.Context()).SendAdminTestMail(r.Context(), strings.TrimSpace(form.Email)); err != nil {
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "invalid"), http.StatusSeeOther)
return
}
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "sent"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityCreatePost(w http.ResponseWriter, r *http.Request) {
var form adminTaskPriorityForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name, active := strings.TrimSpace(form.Name), true
if _, _, err := client.FromContext(r.Context()).CreateAdminTaskPriority(r.Context(), client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &active}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityUpdatePost(w http.ResponseWriter, r *http.Request) {
id, err := app.readPathID(r, "priorityID")
if err != nil {
app.notFound(w)
return
}
var form adminTaskPriorityForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name := strings.TrimSpace(form.Name)
if _, _, err := client.FromContext(r.Context()).UpdateAdminTaskPriority(r.Context(), id, client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &form.Active}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminTaskPriorityDeletePost(w http.ResponseWriter, r *http.Request) {
id, err := app.readPathID(r, "priorityID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminTaskPriority(r.Context(), id); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryCreatePost(w http.ResponseWriter, r *http.Request) {
var form adminSpeciesCategoryForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name, active := strings.TrimSpace(form.Name), true
input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &active, Lifecycle: &form.Lifecycle}
if _, _, err := client.FromContext(r.Context()).CreateAdminSpeciesCategory(r.Context(), input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryUpdatePost(w http.ResponseWriter, r *http.Request) {
categoryID, err := app.readPathID(r, "categoryID")
if err != nil {
app.notFound(w)
return
}
var form adminSpeciesCategoryForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
name := strings.TrimSpace(form.Name)
input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &form.Active, Lifecycle: &form.Lifecycle}
if _, _, err := client.FromContext(r.Context()).UpdateAdminSpeciesCategory(r.Context(), categoryID, input); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminSpeciesCategoryDeletePost(w http.ResponseWriter, r *http.Request) {
categoryID, err := app.readPathID(r, "categoryID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteAdminSpeciesCategory(r.Context(), categoryID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther)
}
func (app *application) adminUserRolePost(w http.ResponseWriter, r *http.Request) {
userID, err := app.readPathID(r, "userID")
if err != nil {
app.notFound(w)
return
}
var form adminRoleForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
if _, _, err := client.FromContext(r.Context()).UpdateAdminUserRole(r.Context(), userID, form.Role); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, adminPath(r, ""), http.StatusSeeOther)
}
func (app *application) settings(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
if !app.loadOptionalGarden(w, r, data) {
return
}
app.render(w, http.StatusOK, "settings.tmpl", data)
}