diff --git a/server/handlers/packages.go b/server/handlers/packages.go index c787193..938f391 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -1183,9 +1183,16 @@ func (h *PackageHandler) ExportPackage(c *gin.Context) { // Walk packagesDir/{id}/ and add all asset files if h.packagesDir == "" { + log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID) + c.Header("X-Export-Warning", "no-assets") return } pkgDir := filepath.Join(h.packagesDir, pkgID) + if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) { + log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID) + c.Header("X-Export-Warning", "no-assets") + return + } filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil diff --git a/server/handlers/workflow_public_handlers.go b/server/handlers/workflow_public_handlers.go index d625644..1995f16 100644 --- a/server/handlers/workflow_public_handlers.go +++ b/server/handlers/workflow_public_handlers.go @@ -36,6 +36,51 @@ type publicInstanceResponse struct { UpdatedAt any `json:"updated_at"` } +// StartBySlug resolves a scope+slug to a workflow and starts a public instance. +// POST /api/v1/workflow-entry/:scope/:slug +// Called by the workflow landing page (/w/:scope/:slug). +func (h *WorkflowPublicHandler) StartBySlug(c *gin.Context) { + scope := c.Param("scope") + slug := c.Param("slug") + + var teamID *string + if scope != "global" { + teamID = &scope + } + + wf, err := h.stores.Workflows.GetBySlug(c.Request.Context(), teamID, slug) + if err != nil || wf == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + + var body struct { + Data json.RawMessage `json:"data"` + } + if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" { + body.Data = json.RawMessage(`{}`) + } + if len(body.Data) == 0 { + body.Data = json.RawMessage(`{}`) + } + + inst, err := h.engine.StartPublic(c.Request.Context(), wf.ID, body.Data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Return redirect for the landing page JS + redirectTo := "/w/" + inst.ID + if inst.EntryToken != nil { + redirectTo = "/w/" + inst.ID + "?token=" + *inst.EntryToken + } + c.JSON(http.StatusCreated, gin.H{ + "id": inst.ID, + "redirect_to": redirectTo, + }) +} + // StartPublic creates an anonymous workflow instance. // POST /api/v1/public/workflows/:id/start func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) { diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index 396718d..cf30316 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -2,11 +2,14 @@ package handlers import ( "database/sql" + "log" "net/http" + "strings" "github.com/gin-gonic/gin" "armature/models" + "armature/store" ) // ── Team-Scoped Workflow Wrappers ──────────────── @@ -39,13 +42,15 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool { // ── Adopt Global Workflow ──────────────────── -// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team. +// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team. +// The global original is left untouched so other teams can also adopt it. // POST /api/v1/teams/:teamId/workflows/:id/adopt func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) { + ctx := c.Request.Context() teamID := c.Param("teamId") - wfID := c.Param("id") + srcID := c.Param("id") - w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID) + src, err := h.stores.Workflows.GetByID(ctx, srcID) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) @@ -54,20 +59,78 @@ func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) { } return } - if w.TeamID != nil { + if src.TeamID != nil { c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"}) return } - patch := models.WorkflowPatch{TeamID: &teamID} - if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"}) + // Load stages from the global workflow + stages, err := h.stores.Workflows.ListStages(ctx, srcID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) return } - // Return the updated workflow - c.Set("id", wfID) - h.Get(c) + // Clone the workflow into this team (global original stays untouched) + clone := &models.Workflow{ + TeamID: &teamID, + Name: src.Name, + Slug: src.Slug, + Description: src.Description, + Branding: src.Branding, + EntryMode: src.EntryMode, + IsActive: false, + Version: 0, + OnComplete: src.OnComplete, + Retention: src.Retention, + WebhookURL: src.WebhookURL, + WebhookSecret: src.WebhookSecret, + StalenessTimeoutHours: src.StalenessTimeoutHours, + CreatedBy: c.GetString("user_id"), + } + + if err := h.stores.Workflows.Create(ctx, clone); err != nil { + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") { + clone.Slug = src.Slug + "-" + store.NewID()[:6] + if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err2.Error()}) + return + } + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err.Error()}) + return + } + } + + // Clone stages + for _, st := range stages { + cloneSt := &models.WorkflowStage{ + WorkflowID: clone.ID, + Ordinal: st.Ordinal, + Name: st.Name, + AssignmentTeamID: st.AssignmentTeamID, + FormTemplate: st.FormTemplate, + StageMode: st.StageMode, + Audience: st.Audience, + StageType: st.StageType, + AutoTransition: st.AutoTransition, + StageConfig: st.StageConfig, + BranchRules: st.BranchRules, + StarlarkHook: st.StarlarkHook, + SurfacePkgID: st.SurfacePkgID, + SLASeconds: st.SLASeconds, + } + if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil { + log.Printf("[workflows] adopt: failed to clone stage %s: %v", st.Name, err) + } + } + + clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID) + if clonedStages == nil { + clonedStages = []models.WorkflowStage{} + } + clone.Stages = clonedStages + c.JSON(http.StatusCreated, clone) } // ListGlobalWorkflows returns unowned workflows available for adoption. diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index ad0c953..aa535f7 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -149,8 +149,24 @@ func (h *WorkflowHandler) Update(c *gin.Context) { // Delete deletes a workflow and all its stages/versions (CASCADE). // DELETE /api/v1/workflows/:id +// Admin-only: only allows deleting global (team_id IS NULL) workflows. +// Team-scoped workflows must be deleted via the team endpoint. func (h *WorkflowHandler) Delete(c *gin.Context) { - if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil { + ctx := c.Request.Context() + wfID := c.Param("id") + + // Guard: prevent accidental deletion of team-scoped workflows from the admin endpoint + wf, err := h.stores.Workflows.GetByID(ctx, wfID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + if wf.TeamID != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "team-scoped workflows must be deleted via the team endpoint"}) + return + } + + if err := h.stores.Workflows.Delete(ctx, wfID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"}) return } diff --git a/server/main.go b/server/main.go index 02b135c..ff36656 100644 --- a/server/main.go +++ b/server/main.go @@ -444,6 +444,10 @@ func main() { publicWf.POST("/advance/:token", publicWfH.AdvancePublic) } + // Landing-page entry: POST /api/v1/workflow-entry/:scope/:slug + // Resolves scope+slug → workflow ID, starts instance, returns redirect. + api.POST("/workflow-entry/:scope/:slug", authLimiter.Limit(), publicWfH.StartBySlug) + // ── Workflow Scanner ────────── wfScanner := workflow.NewScanner(stores, bus) wfScanner.Start() diff --git a/src/css/surfaces.css b/src/css/surfaces.css index 3c30eaa..37f9c99 100644 --- a/src/css/surfaces.css +++ b/src/css/surfaces.css @@ -238,7 +238,7 @@ .bar-chart-label { font-size: 10px; color: var(--text-3); } /* ── Admin Settings Form (flat sections, prototype match) ── */ -.admin-settings-form { max-width: 600px; } +.admin-settings-form { max-width: 600px; padding-bottom: var(--sp-12); } .admin-settings-form .settings-section { background: none; border: none; border-radius: 0; padding: 0 0 16px; margin-bottom: 20px; diff --git a/src/js/sw/components/stage-form.js b/src/js/sw/components/stage-form.js new file mode 100644 index 0000000..67f892d --- /dev/null +++ b/src/js/sw/components/stage-form.js @@ -0,0 +1,177 @@ +/** + * Shared Stage Form Component + * + * Used by both admin/workflows.js and team-admin/workflow-editor.js + * for creating and editing workflow stages. + * + * Props: + * stage - existing stage object (null for new) + * teams - array of { id, name } for team assignment dropdown + * onSave - (data) => void — called with stage payload + * onCancel - () => void + */ +const { html } = window; +const { useState, useEffect } = hooks; + +export const STAGE_MODES = ['form', 'review', 'delegated', 'automated']; +export const STAGE_TYPES = ['simple', 'dynamic', 'automated']; +export const AUDIENCES = ['team', 'public', 'system']; + +export function StageForm({ stage, teams, onSave, onCancel }) { + const [name, setName] = useState(stage?.name || ''); + const [mode, setMode] = useState(stage?.stage_mode || 'form'); + const [audience, setAudience] = useState(stage?.audience || 'team'); + const [stageType, setStageType] = useState(stage?.stage_type || 'simple'); + const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || ''); + const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); + const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); + const [sla, setSla] = useState(stage?.sla_seconds || ''); + const [branchRules, setBranchRules] = useState( + stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : '' + ); + + const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {}; + const [requiredRole, setRequiredRole] = useState(sc.required_role || ''); + const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || ''); + const [valRole, setValRole] = useState(sc.validation?.required_role || ''); + const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel'); + const [teamRoles, setTeamRoles] = useState(['admin', 'member']); + + useEffect(() => { + if (!assignTeam) return; + sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {}); + }, [assignTeam]); + + function submit() { + const stageConfig = {}; + if (requiredRole) stageConfig.required_role = requiredRole; + if (valApprovals) { + stageConfig.validation = { + required_approvals: parseInt(valApprovals, 10), + ...(valRole ? { required_role: valRole } : {}), + reject_action: valReject || 'cancel', + }; + } + let parsedBranch = null; + if (branchRules.trim()) { + try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; } + } + onSave({ + name, + stage_mode: mode, + audience, + stage_type: stageType, + starlark_hook: starlarkHook || null, + assignment_team_id: assignTeam || null, + auto_transition: autoTransition, + sla_seconds: sla ? parseInt(sla, 10) : null, + stage_config: Object.keys(stageConfig).length ? stageConfig : {}, + branch_rules: parsedBranch || [], + }); + } + + return html` +