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.
261 lines
9.1 KiB
Go
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)
|
|
}
|