Files
member-console/internal/server/workspace_partials.go
Christian Galo 675a4d93a3 Buffer template rendering and fix FedWiki sync
Introduce SafeTemplates.Render to execute templates into a buffer and
prevent partial HTML on errors. Replace direct ExecuteTemplate calls in
partial handlers and add a make lint-templates target to catch bypasses.
Update operator sites template/view model to use OwnerOrgName. Guard the
FedWiki sync by skipping inserts when DefaultWorkspaceID is empty and
scope deletes to the configured default workspace only.
2026-03-29 04:58:02 -05:00

210 lines
6.4 KiB
Go

package server
import (
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// WorkspacePartialsHandler handles HTMX partial requests for workspace management.
type WorkspacePartialsHandler struct {
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
AuthConfig *auth.Config
Logger *slog.Logger
Templates *SafeTemplates
}
// WorkspacePartialsConfig holds configuration for the workspace partials handler.
type WorkspacePartialsConfig struct {
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
AuthConfig *auth.Config
Logger *slog.Logger
}
// NewWorkspacePartialsHandler creates a new WorkspacePartialsHandler.
func NewWorkspacePartialsHandler(cfg WorkspacePartialsConfig) (*WorkspacePartialsHandler, error) {
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
if err != nil {
return nil, err
}
tmpl, err := template.ParseFS(templateSubFS, "workspace_*.html")
if err != nil {
return nil, err
}
return &WorkspacePartialsHandler{
OrgQ: cfg.OrgQ,
EntitlementsQ: cfg.EntitlementsQ,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
Templates: NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// RegisterRoutes registers workspace management routes.
func (h *WorkspacePartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/workspaces", h.GetWorkspaces)
mux.HandleFunc("GET /partials/workspaces/create-form", h.GetCreateForm)
mux.HandleFunc("POST /partials/workspaces", h.CreateWorkspace)
mux.HandleFunc("POST /partials/workspaces/{workspaceID}/switch", h.SwitchWorkspace)
}
// WorkspaceViewModel represents a workspace for template rendering.
type WorkspaceViewModel struct {
WorkspaceID string
Name string
Slug string
Status string
CreatedAt string
IsActive bool
}
// WorkspaceListData holds data for the workspace list partial.
type WorkspaceListData struct {
Workspaces []WorkspaceViewModel
Success string
Error string
}
// WorkspaceCreateData holds data for the workspace create form partial.
type WorkspaceCreateData struct {
Error string
}
// GetWorkspaces handles GET /partials/workspaces
func (h *WorkspacePartialsHandler) GetWorkspaces(w http.ResponseWriter, r *http.Request) {
h.renderWorkspaceList(w, r, "", "")
}
// GetCreateForm handles GET /partials/workspaces/create-form
func (h *WorkspacePartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Request) {
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{})
}
// CreateWorkspace handles POST /partials/workspaces
func (h *WorkspacePartialsHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
h.renderCreateFormError(w, "Invalid request")
return
}
name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(strings.ToLower(r.FormValue("slug")))
if name == "" || slug == "" {
h.renderCreateFormError(w, "Name and slug are required")
return
}
// Create the workspace
ws, err := h.OrgQ.CreateWorkspace(r.Context(), organization.CreateWorkspaceParams{
OrgID: session.OrgID,
Name: name,
Slug: slug,
})
if err != nil {
h.Logger.Error("failed to create workspace", slog.Any("error", err))
if strings.Contains(err.Error(), "unique") {
h.renderCreateFormError(w, "A workspace with that slug already exists in this organization")
return
}
h.renderCreateFormError(w, "Failed to create workspace: "+err.Error())
return
}
// Create pool assignment for the new workspace (link to org's default pool)
pool, err := h.EntitlementsQ.GetDefaultPoolByOrgID(r.Context(), session.OrgID)
if err == nil {
_, err = h.EntitlementsQ.CreatePoolAssignment(r.Context(), entitlements.CreatePoolAssignmentParams{
PoolID: pool.PoolID,
WorkspaceID: ws.WorkspaceID,
IsPrimary: true,
})
if err != nil {
h.Logger.Error("failed to create pool assignment", slog.Any("error", err))
}
}
h.renderWorkspaceList(w, r, "Workspace created successfully.", "")
}
// SwitchWorkspace handles POST /partials/workspaces/{workspaceID}/switch
func (h *WorkspacePartialsHandler) SwitchWorkspace(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
workspaceID := r.PathValue("workspaceID")
// Verify workspace belongs to user's org
ws, err := h.OrgQ.GetWorkspaceByID(r.Context(), workspaceID)
if err != nil {
h.Logger.Error("failed to get workspace", slog.Any("error", err))
h.renderWorkspaceList(w, r, "", "Workspace not found")
return
}
if ws.OrgID != session.OrgID {
h.renderWorkspaceList(w, r, "", "Workspace does not belong to your organization")
return
}
// Update session workspace
h.AuthConfig.SessionManager.Put(r.Context(), "workspace_id", workspaceID)
// Trigger full page refresh so all workspace-scoped views update
w.Header().Set("HX-Refresh", "true")
}
func (h *WorkspacePartialsHandler) renderWorkspaceList(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
session := h.AuthConfig.GetUserSession(r.Context())
data := WorkspaceListData{
Success: success,
Error: errMsg,
}
if session != nil && errMsg == "" {
workspaces, err := h.OrgQ.GetWorkspacesByOrgID(r.Context(), session.OrgID)
if err != nil {
h.Logger.Error("failed to list workspaces", slog.Any("error", err))
data.Error = "Failed to load workspaces"
} else {
data.Workspaces = make([]WorkspaceViewModel, len(workspaces))
for i, ws := range workspaces {
data.Workspaces[i] = WorkspaceViewModel{
WorkspaceID: ws.WorkspaceID,
Name: ws.Name,
Slug: ws.Slug,
Status: ws.Status,
CreatedAt: ws.CreatedAt.Format("Jan 2, 2006"),
IsActive: ws.WorkspaceID == session.WorkspaceID,
}
}
}
}
h.Templates.Render(w, "workspace_list.html", data)
}
func (h *WorkspacePartialsHandler) renderCreateFormError(w http.ResponseWriter, message string) {
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{Error: message})
}