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.
210 lines
6.4 KiB
Go
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})
|
|
}
|