Files
member-console/internal/server/workspace_partials.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

261 lines
9.1 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"database/sql"
"html/template"
"io/fs"
"log/slog"
"net/http"
"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/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
// WorkspacePartialsHandler handles HTMX partial requests for workspace management.
type WorkspacePartialsHandler struct {
OrgQ organization.Querier
EntitlementsQ entitlements.Querier
// Database opens the transaction workspace creation runs in, so the
// workspace insert and its primary pool assignment commit or roll back
// together (schema-hardening design D2).
Database *sql.DB
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
Database *sql.DB
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.New("workspace-partials").Funcs(template.FuncMap{
"routeURL": web.RouteURL,
}).ParseFS(templateSubFS, "workspace_*.html")
if err != nil {
return nil, err
}
if tmpl, err = web.ParseUIPartials(tmpl); err != nil {
return nil, err
}
return &WorkspacePartialsHandler{
OrgQ: cfg.OrgQ,
EntitlementsQ: cfg.EntitlementsQ,
Database: cfg.Database,
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
Status string
CreatedAt string
IsActive bool
// CanSwitch gates the Switch button: only active workspaces may become
// the session workspace (matching SwitchWorkspace's server-side refusal).
CanSwitch bool
}
// WorkspaceListData holds data for the workspace list partial.
type WorkspaceListData struct {
Workspaces []WorkspaceViewModel
Error string
}
// WorkspaceCreateData holds data for the workspace create form partial.
type WorkspaceCreateData struct {
Form forms.FormView
}
// 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) {
view := forms.Render(workspaceCreateForm, forms.Binding{Mode: forms.ModeUnbound})
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{Form: view})
}
// 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
}
values, errs := workspaceCreateForm.Parse(r)
if errs.Any() {
h.renderCreateFormRefusal(w, values, errs)
return
}
name := values.String("name")
// Create the workspace and its primary pool assignment together, in one
// transaction, through the same shared provisioning function signup
// uses -- either both happen or neither does (schema-hardening design
// D2). Previously this handler created the workspace, then tried the
// pool assignment as a best-effort follow-up step and reported success
// even when that step failed, leaving a workspace with no pool.
tx, err := h.Database.BeginTx(r.Context(), nil)
if err != nil {
h.Logger.Error("failed to begin workspace create transaction", slog.Any("error", err))
errs.Form("We could not create the workspace. Try again.")
h.renderCreateFormRefusal(w, values, errs)
return
}
defer tx.Rollback()
_, err = provisioning.CreateWorkspaceWithPrimaryAssignment(r.Context(), tx, session.OrgID, name)
if err != nil {
// Translate constraint violations into friendly text (never raw
// driver text). The workspace name is unique within the
// organization among live workspaces
// (uq_workspaces_org_id_name_ci, entity-keys); a
// duplicate is something the member typed and can fix, so it renders
// on the name field with 422, the validation contract the rest of
// the console uses (docs/operator-ux-conventions.md §6).
if fe, ok := web.FieldErrorsFromDB(err, web.ConstraintMessages{
"uq_workspaces_org_id_name_ci": {Field: "name", Message: "A workspace with this name already exists in this organization."},
}); ok {
msg := fe.Get("name")
if msg == "" {
msg = fe.Get("")
}
errs.Field("name", msg)
h.renderCreateFormRefusal(w, values, errs)
return
}
h.Logger.Error("failed to create workspace", slog.Any("error", err))
errs.Form("We could not create the workspace. Try again.")
h.renderCreateFormRefusal(w, values, errs)
return
}
if err := tx.Commit(); err != nil {
h.Logger.Error("failed to commit workspace create transaction", slog.Any("error", err))
errs.Form("We could not create the workspace. Try again.")
h.renderCreateFormRefusal(w, values, errs)
return
}
fireSuccessToast(w, "Workspace created successfully.")
h.renderWorkspaceList(w, r, "")
}
// renderCreateFormRefusal re-renders the create form at 422 with the
// declaration in submission mode: the submitted name carried back, its
// error under the control when the refusal is the name's own, and every
// other refusal in the form-level slot the part always renders (design D9;
// finding FA-22, which named this path as one of the ones that used to
// answer 200 regardless of outcome).
func (h *WorkspacePartialsHandler) renderCreateFormRefusal(w http.ResponseWriter, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
view := forms.Render(workspaceCreateForm, forms.Binding{Mode: forms.ModeSubmission, Values: values, Errors: errs})
h.Templates.Render(w, "workspace_create.html", WorkspaceCreateData{Form: view})
}
// 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
}
// Interim guard (audit #50): only active workspaces can become the
// session workspace; full status semantics await the workspace-identity
// model.
if ws.Status != "active" {
h.renderWorkspaceList(w, r, "That workspace isn't active; it cannot be switched to.")
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, errMsg string) {
session := h.AuthConfig.GetUserSession(r.Context())
data := WorkspaceListData{
Error: errMsg,
}
if session != nil {
workspaces, err := h.OrgQ.GetWorkspacesByOrgID(r.Context(), session.OrgID)
if err != nil {
h.Logger.Error("failed to list workspaces", slog.Any("error", err))
if data.Error == "" {
data.Error = "Failed to load workspaces"
}
} else {
data.Workspaces = make([]WorkspaceViewModel, len(workspaces))
for i, ws := range workspaces {
isActive := ws.WorkspaceID == session.WorkspaceID
data.Workspaces[i] = WorkspaceViewModel{
WorkspaceID: ws.WorkspaceID,
Name: ws.Name,
Status: ws.Status,
CreatedAt: ws.CreatedAt.Format("Jan 2, 2006"),
IsActive: isActive,
CanSwitch: !isActive && ws.Status == "active",
}
}
}
}
h.Templates.Render(w, "workspace_list.html", data)
}