This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/pages/pages.go
Jeffrey Smith 110f128661 Fix workflow entry token resolution and fieldset submit guard
RenderWorkflow() was passing the route param (instance ID) as the
entry token to the template JS. The advance API needs the actual
entry token from the instance. Now resolves from inst.EntryToken
and checks the ?token= query param from the landing page redirect.

Also fix submitForm() early-return guard that blocked progressive
forms using fieldsets (checked FORM_TPL.fields but not .fieldsets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:21:38 +00:00

1033 lines
30 KiB
Go

// Package pages provides server-side HTML rendering via Go templates.
//
// Templates are embedded in the binary via //go:embed and organized as:
//
// templates/base.html — outer shell (banner + surface block + scripts)
// templates/login.html — standalone login page
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
// templates/surfaces/*.html — one per surface (admin, settings, team-admin, extension)
package pages
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"strings"
"sync"
"github.com/gin-gonic/gin"
"armature/config"
"armature/models"
"armature/store"
)
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html
var templateFS embed.FS
// Engine holds parsed templates and rendering state.
type Engine struct {
mu sync.RWMutex
templates *template.Template
cfg *config.Config
stores store.Stores
loaders map[string]DataLoaderFunc
surfaces []SurfaceManifest
devMode bool
}
// SurfaceManifest describes a surface's registration. Core surfaces are
// registered in Go at startup. Extension surfaces will be registered from
// manifest files (future).
type SurfaceManifest struct {
ID string `json:"id"` // unique identifier: "admin", "settings", "my-dashboard"
Route string `json:"route"` // primary URL pattern: "/", "/admin"
AltRoutes []string `json:"alt_routes"` // additional URL patterns
Title string `json:"title"` // human-readable: "Admin", "Settings"
Template string `json:"template"` // Go template name: "surface-admin", "surface-extension"
Components []string `json:"components"` // component IDs used: ["file-tree"]
DataRequires []string `json:"data_requires"` // data loader keys: ["workspace", "models"]
Scripts []string `json:"scripts"` // JS files (surface-specific, beyond base.html common)
Styles []string `json:"styles"` // CSS files (surface-specific)
Auth string `json:"auth"` // "authenticated", "public", "session", "admin"
Layout string `json:"layout"` // default pane layout preset: "single", "editor"
Source string `json:"source"` // "core" or "extension"
}
// BannerConfig holds environment banner settings.
type BannerConfig struct {
Text string `json:"text"`
Color string `json:"color"`
Background string `json:"background"`
Visible bool `json:"visible"`
}
// MessageConfig holds dismissible message bar settings.
type MessageConfig struct {
Text string `json:"text"`
Variant string `json:"variant"` // info, warn, error, success
Visible bool `json:"visible"`
}
// FooterConfig holds optional footer bar settings.
type FooterConfig struct {
Text string `json:"text"`
Visible bool `json:"visible"`
}
// PageData is passed to every template render.
type PageData struct {
Banner BannerConfig
Message MessageConfig
Footer FooterConfig
Surface string // active surface ID
Section string // sub-section (for admin pages)
CSPNonce string
BasePath string
Version string
Environment string
User *UserContext
Data any // surface-specific data from loader
Theme string // "dark", "light", or "" (= use default)
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
Manifest *SurfaceManifest `json:"manifest,omitempty"`
EnabledSurfaces []string `json:"-"`
ExtensionSurfaces []ExtensionNavItem `json:"-"`
BrowserExtensions []string `json:"-"` // IDs of enabled browser-tier extensions (for script injection)
InstanceName string // branding: instance display name
LogoURL string // branding: custom logo URL
Tagline string // branding: tagline under instance name
RegistrationOpen bool // whether self-registration is enabled
AuthMode string // "builtin", "mtls", "oidc"
}
// SurfaceEnabled returns true if the given surface ID is in the EnabledSurfaces list.
// Used by templates: {{if .SurfaceEnabled "editor"}}
func (pd PageData) SurfaceEnabled(id string) bool {
for _, s := range pd.EnabledSurfaces {
if s == id {
return true
}
}
return false
}
// ExtensionNavItem holds the minimal info needed to render an extension
// surface link in the sidebar nav.
type ExtensionNavItem struct {
ID string
Title string
Route string
}
// ConfigSectionEntry describes a package-declared config section for
// lazy-loading in Settings, Admin, or Team Admin surfaces.
type ConfigSectionEntry struct {
PackageID string `json:"package_id"` // section key & asset path prefix
Label string `json:"label"` // nav link text
Icon string `json:"icon"` // optional SVG path data (admin CatIcon format)
Component string `json:"component"` // relative asset path, e.g. "js/config.js"
Category string `json:"category"` // admin only: category tab (default "system")
}
// extensionNavItems returns enabled extension surfaces for sidebar rendering.
// Queries the DB at render time so newly installed surfaces appear immediately.
func (e *Engine) extensionNavItems() []ExtensionNavItem {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
surfaces, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load extension surfaces for nav: %v", err)
return nil
}
var items []ExtensionNavItem
for _, sr := range surfaces {
if sr.Source == "core" || !sr.Enabled {
continue
}
// Only surface and full types have routes — extensions are headless
if sr.Type != "surface" && sr.Type != "full" {
continue
}
route := ""
if sr.Manifest != nil {
route, _ = sr.Manifest["route"].(string)
}
if route == "" {
route = "/s/" + sr.ID
}
items = append(items, ExtensionNavItem{
ID: sr.ID,
Title: sr.Title,
Route: route,
})
}
return items
}
// browserExtensionIDs returns IDs of enabled browser-tier extensions.
// These extensions have JS scripts that should be injected into every page
// so they can register renderers with the SDK.
func (e *Engine) browserExtensionIDs() []string {
if e.stores.Packages == nil {
return nil
}
ctx := context.Background()
pkgs, err := e.stores.Packages.List(ctx)
if err != nil {
log.Printf("[pages] Failed to load browser extensions: %v", err)
return nil
}
var ids []string
for _, pkg := range pkgs {
if pkg.Enabled && pkg.Type == "extension" && pkg.Tier == "browser" {
ids = append(ids, pkg.ID)
}
}
return ids
}
// UserContext is the authenticated user's info available to templates.
type UserContext struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Email string `json:"email"`
}
// DataLoaderFunc loads surface-specific data for template rendering.
type DataLoaderFunc func(c *gin.Context, stores store.Stores) (any, error)
// New creates a template engine. Call after config and stores are ready.
func New(cfg *config.Config, stores store.Stores) *Engine {
e := &Engine{
cfg: cfg,
stores: stores,
loaders: make(map[string]DataLoaderFunc),
devMode: gin.Mode() == gin.DebugMode,
}
e.parseTemplates()
e.registerLoaders()
e.registerCoreSurfaces()
e.SeedSurfaces()
return e
}
// registerCoreSurfaces populates the surface manifest list with the kernel
// surfaces. Extension surfaces are appended from DB at startup.
func (e *Engine) registerCoreSurfaces() {
e.surfaces = []SurfaceManifest{
{
ID: "admin", Route: "/admin/:section", AltRoutes: []string{"/admin"},
Title: "Admin", Template: "surface-admin", Auth: "admin",
DataRequires: []string{"admin"},
Layout: "single", Source: "core",
},
{
ID: "settings", Route: "/settings/:section", AltRoutes: []string{"/settings"},
Title: "Settings", Template: "surface-settings", Auth: "authenticated",
DataRequires: []string{"settings"},
Layout: "single", Source: "core",
},
{
ID: "team-admin", Route: "/team-admin/:section", AltRoutes: []string{"/team-admin"},
Title: "Team Admin", Template: "surface-team-admin", Auth: "authenticated",
DataRequires: []string{"team-admin"},
Layout: "single", Source: "core",
},
{
ID: "workflow", Route: "/w/:id",
Title: "Workflow", Template: "workflow", Auth: "session",
DataRequires: []string{"workflow"},
Layout: "single", Source: "core",
},
{
ID: "workflow-landing", Route: "/w/:id/:slug",
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
Layout: "single", Source: "core",
},
{
ID: "welcome", Route: "/welcome",
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
Layout: "single", Source: "core",
},
{
ID: "docs", Route: "/docs/:section", AltRoutes: []string{"/docs"},
Title: "Docs", Template: "surface-docs", Auth: "authenticated",
Layout: "single", Source: "core",
},
}
}
// GetSurface returns the manifest for a surface by ID, or nil if not found.
func (e *Engine) GetSurface(id string) *SurfaceManifest {
for i := range e.surfaces {
if e.surfaces[i].ID == id {
return &e.surfaces[i]
}
}
return nil
}
// Surfaces returns all registered surface manifests.
func (e *Engine) Surfaces() []SurfaceManifest {
return e.surfaces
}
// parseTemplates compiles all embedded templates with the custom FuncMap.
func (e *Engine) parseTemplates() {
funcMap := template.FuncMap{
"toJSON": toJSON,
"eq": func(a, b string) bool { return a == b },
"contains": stringContains,
"esc": template.HTMLEscapeString,
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
"join": strings.Join,
"dict": dict,
"or": tmplOr,
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/*.html",
"templates/components/*.html",
"templates/surfaces/*.html",
)
if err != nil {
log.Fatalf("❌ Failed to parse templates: %v", err)
}
e.mu.Lock()
e.templates = tmpl
e.mu.Unlock()
var names []string
for _, t := range tmpl.Templates() {
if t.Name() != "" {
names = append(names, t.Name())
}
}
log.Printf("[pages] Parsed %d templates", len(names))
}
// RegisterLoader adds a named data loader for a surface.
func (e *Engine) RegisterLoader(name string, fn DataLoaderFunc) {
e.loaders[name] = fn
}
// Render renders a named template into the response.
func (e *Engine) Render(c *gin.Context, name string, data PageData) {
data.BasePath = e.cfg.BasePath
data.Version = Version
data.Environment = e.cfg.Environment
data.CSPNonce = generateNonce()
data.Banner = e.loadBanner()
data.Message = e.loadMessage()
data.Footer = e.loadFooter()
if data.Theme == "" {
data.Theme = "dark"
}
e.mu.RLock()
tmpl := e.templates
e.mu.RUnlock()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
log.Printf("[pages] Template render error (%s): %v", name, err)
}
}
// RenderSurface is a Gin handler factory for surface routes.
func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
return func(c *gin.Context) {
user := e.getUserContext(c)
var data any
if loader, ok := e.loaders[surfaceID]; ok {
var err error
data, err = loader(c, e.stores)
if err != nil {
log.Printf("[pages] Data loader error (%s): %v", surfaceID, err)
c.String(http.StatusInternalServerError, "Failed to load page data")
return
}
}
section := c.Param("section")
if section == "" && surfaceID == "admin" {
section = "users"
}
if section == "" && surfaceID == "settings" {
section = "general"
}
if section == "" && surfaceID == "team-admin" {
section = "members"
}
if section == "" && surfaceID == "docs" {
section = "GETTING-STARTED"
}
e.Render(c, "base.html", PageData{
Surface: surfaceID,
Section: section,
User: user,
Data: data,
Manifest: e.GetSurface(surfaceID),
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}
// RenderExtensionSurface handles /s/:slug — dynamic DB lookup for extension surfaces.
// No restart required after installing a surface via admin API.
func (e *Engine) RenderExtensionSurface() gin.HandlerFunc {
return func(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
c.String(http.StatusNotFound, "Surface not found")
return
}
if e.stores.Packages == nil {
c.String(http.StatusNotFound, "Surface registry not available")
return
}
// Look up by ID (slug == surface ID)
sr, err := e.stores.Packages.Get(c.Request.Context(), slug)
if err != nil || sr == nil {
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
if !sr.Enabled {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/admin")
return
}
if sr.Source == "core" {
// Core surfaces have their own routes — don't serve them here
c.String(http.StatusNotFound, "Surface not found: "+slug)
return
}
user := e.getUserContext(c)
// Build manifest from DB record
route, _ := sr.Manifest["route"].(string)
if route == "" {
route = "/s/" + sr.ID
}
layout, _ := sr.Manifest["layout"].(string)
if layout == "" {
layout = "single"
}
manifest := &SurfaceManifest{
ID: sr.ID,
Route: route,
Title: sr.Title,
Template: "surface-extension",
Layout: layout,
Source: "extension",
}
e.Render(c, "base.html", PageData{
Surface: sr.ID,
User: user,
Manifest: manifest,
EnabledSurfaces: e.EnabledSurfaceIDs(),
ExtensionSurfaces: e.extensionNavItems(),
BrowserExtensions: e.browserExtensionIDs(),
})
}
}
// PageRouteMiddleware holds middleware handlers for each auth level.
// Passed to RegisterPageRoutes by main.go.
type PageRouteMiddleware struct {
Authenticated gin.HandlerFunc // AuthOrRedirect — for authenticated surfaces
Admin []gin.HandlerFunc // AuthOrRedirect + RequireAdminPage
Session gin.HandlerFunc // AuthOrSession — for workflow
}
// RegisterPageRoutes generates Gin routes from the registered surface manifests.
// Replaces manual per-surface route blocks in main.go.
//
// Groups surfaces by auth level to avoid redundant middleware chains.
// Workflow surfaces use RenderWorkflow() (separate template + data model).
// All other surfaces use RenderSurface().
func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddleware) {
// Group surfaces by auth type
byAuth := map[string][]SurfaceManifest{}
for _, s := range e.surfaces {
byAuth[s.Auth] = append(byAuth[s.Auth], s)
}
// Authenticated surfaces — single middleware group
if surfaces, ok := byAuth["authenticated"]; ok && mw.Authenticated != nil {
group := base.Group("")
group.Use(mw.Authenticated)
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
// Disabled surface → redirect to chat
registerRoutes(group, s, e.disabledRedirect())
continue
}
handler := e.RenderSurface(s.ID)
// Welcome auto-redirects to / when extension surfaces exist
if s.ID == "welcome" {
handler = e.welcomeRedirectIfSurfaces(handler)
}
registerRoutes(group, s, handler)
}
// No restart needed after installing new surfaces via admin API.
group.GET("/s/:slug", e.RenderExtensionSurface())
}
// Admin surfaces — auth + admin role middleware
if surfaces, ok := byAuth["admin"]; ok && len(mw.Admin) > 0 {
for _, s := range surfaces {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
for _, m := range mw.Admin {
group.Use(m)
}
// Admin surfaces are always enabled (needed to manage other surfaces)
handler := e.RenderSurface(s.ID)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Session surfaces (workflow) — session middleware
if surfaces, ok := byAuth["session"]; ok && mw.Session != nil {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
registerRoutesRelative(group, prefix, s, e.disabledRedirect())
continue
}
prefix := routePrefix(s.Route)
group := base.Group(prefix)
group.Use(mw.Session)
handler := e.surfaceHandler(s)
registerRoutesRelative(group, prefix, s, handler)
}
}
// Public surfaces — no middleware
if surfaces, ok := byAuth["public"]; ok {
for _, s := range surfaces {
if !e.IsSurfaceEnabled(s.ID) {
registerRoutes(base, s, e.disabledRedirect())
continue
}
handler := e.surfaceHandler(s)
registerRoutes(base, s, handler)
}
}
log.Printf("[pages] Registered routes for %d surfaces", len(e.surfaces))
}
// surfaceHandler returns the appropriate Gin handler for a surface.
func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
if s.ID == "workflow" {
return e.RenderWorkflow()
}
if s.ID == "workflow-landing" {
return e.RenderWorkflowLanding()
}
return e.RenderSurface(s.ID)
}
// registerRoutes adds the primary route and alt routes for a surface.
// Used for surfaces in the root group (authenticated).
func registerRoutes(group *gin.RouterGroup, s SurfaceManifest, handler gin.HandlerFunc) {
group.GET(s.Route, handler)
for _, alt := range s.AltRoutes {
if alt != s.Route {
group.GET(alt, handler)
}
}
}
// registerRoutesRelative adds routes relative to a group prefix.
// Used for admin/session surfaces with their own prefix group.
// "/admin/:section" with prefix "/admin" → "/:section"
func registerRoutesRelative(group *gin.RouterGroup, prefix string, s SurfaceManifest, handler gin.HandlerFunc) {
rel := stripPrefix(s.Route, prefix)
group.GET(rel, handler)
for _, alt := range s.AltRoutes {
r := stripPrefix(alt, prefix)
if r != rel {
group.GET(r, handler)
}
}
}
// routePrefix extracts the first path segment as a group prefix.
// "/admin/:section" → "/admin", "/w/:id" → "/w", "/" → ""
func routePrefix(route string) string {
trimmed := strings.TrimPrefix(route, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) == 0 || parts[0] == "" || strings.HasPrefix(parts[0], ":") {
return ""
}
return "/" + parts[0]
}
// stripPrefix removes a prefix from a route path. Returns "" if the
// entire path IS the prefix (which Gin interprets as the group root).
func stripPrefix(route, prefix string) string {
if prefix == "" {
return route
}
rel := strings.TrimPrefix(route, prefix)
if rel == "" {
rel = ""
}
return rel
}
// RenderLogin serves the standalone login page.
func (e *Engine) RenderLogin() gin.HandlerFunc {
return func(c *gin.Context) {
instanceName, logoURL, tagline := e.loadBranding()
regOpen := e.isRegistrationOpen()
e.Render(c, "login.html", PageData{
Surface: "login",
InstanceName: instanceName,
LogoURL: logoURL,
Tagline: tagline,
RegistrationOpen: regOpen,
AuthMode: e.cfg.AuthMode,
})
}
}
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct {
EntryToken string
WorkflowTitle string
WorkflowDescription string
SessionID string
SessionName string
StageMode string // form | review | delegated | automated
StageName string
FormTemplateJSON string // typed form template JSON (empty if delegated)
TotalStages int
CurrentStage int
SurfacePkgID string
BrandingJSON string
InstanceID string // workflow instance ID (used for API calls)
Status string // pending | active | completed | cancelled | stale
}
// WorkflowLandingPageData is passed to workflow-landing.html.
type WorkflowLandingPageData struct {
WorkflowID string
Scope string
Slug string
Name string
Description string
Branding struct {
AccentColor string `json:"accent_color"`
LogoURL string `json:"logo_url"`
Tagline string `json:"tagline"`
}
PersonaName string
PersonaIcon string
StageCount int
FirstStageMode string // form | review | delegated | automated
ResumeURL string // non-empty if visitor has an active session
}
// RenderWorkflow renders the workflow execution page for a running instance.
// Route: /w/:id — :id is either an instance ID or a public entry token.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
routeID := c.Param("id")
sessionID := c.GetString("session_id")
sessionName := "Visitor"
instanceName, _, _ := e.loadBranding()
// Try to load instance — the route param may be an instance ID
// or an entry token. Also check the ?token= query param.
var inst *models.WorkflowInstance
if e.stores.Workflows != nil {
// First try: query param token (from landing page redirect)
if qToken := c.Query("token"); qToken != "" {
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, qToken)
}
// Second try: route param as entry token
if inst == nil {
inst, _ = e.stores.Workflows.GetInstanceByToken(ctx, routeID)
}
// Third try: route param as instance ID
if inst == nil {
inst, _ = e.stores.Workflows.GetInstance(ctx, routeID)
}
}
if inst == nil {
c.String(http.StatusNotFound, "Workflow instance not found")
return
}
// Load workflow definition for title, description, branding
title, description := "Workflow", ""
var brandingJSON string
wf, _ := e.stores.Workflows.GetByID(ctx, inst.WorkflowID)
if wf != nil {
title = wf.Name
description = wf.Description
if len(wf.Branding) > 0 {
brandingJSON = string(wf.Branding)
}
}
// Load stages to resolve current stage info
var stageMode, stageName, formTplJSON, surfacePkgID string
var totalStages, currentStage int
stageMode = "form" // default
stages, _ := e.stores.Workflows.ListStages(ctx, inst.WorkflowID)
totalStages = len(stages)
for i, s := range stages {
if s.ID == inst.CurrentStage || s.Name == inst.CurrentStage {
currentStage = i
stageName = s.Name
stageMode = s.StageMode
if stageMode == "" {
stageMode = "form"
}
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "null" {
formTplJSON = string(s.FormTemplate)
}
if s.SurfacePkgID != nil {
surfacePkgID = *s.SurfacePkgID
}
break
}
}
// Resolve the actual entry token from the instance (used by JS API calls)
entryToken := ""
if inst.EntryToken != nil {
entryToken = *inst.EntryToken
}
e.Render(c, "workflow.html", PageData{
Surface: "workflow",
InstanceName: instanceName,
Data: WorkflowPageData{
EntryToken: entryToken,
WorkflowTitle: title,
WorkflowDescription: description,
SessionID: sessionID,
SessionName: sessionName,
StageMode: stageMode,
StageName: stageName,
FormTemplateJSON: formTplJSON,
TotalStages: totalStages,
CurrentStage: currentStage,
SurfacePkgID: surfacePkgID,
BrandingJSON: brandingJSON,
InstanceID: inst.ID,
Status: inst.Status,
},
})
}
}
// RenderWorkflowLanding renders the public landing page for a workflow.
// Visitors see the workflow name, description, persona info, and a Start button.
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
// :slug is the workflow slug. Same :id param name as /w/:id
// (chat surface) to avoid Gin wildcard conflicts.
scope := c.Param("id")
slug := c.Param("slug")
// Resolve scope to team_id
var teamID *string
if scope != "global" {
teamID = &scope
}
if e.stores.Workflows == nil {
c.String(http.StatusNotFound, "Workflows not available")
return
}
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
if err != nil || wf == nil {
c.String(http.StatusNotFound, "Workflow not found")
return
}
if !wf.IsActive {
c.String(http.StatusNotFound, "Workflow not available")
return
}
data := WorkflowLandingPageData{
WorkflowID: wf.ID,
Scope: scope,
Slug: slug,
Name: wf.Name,
Description: wf.Description,
}
// Parse branding
if len(wf.Branding) > 0 {
_ = json.Unmarshal(wf.Branding, &data.Branding)
}
// Load stages for count + first persona info + stage mode
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
data.StageCount = len(stages)
if len(stages) > 0 {
data.FirstStageMode = stages[0].StageMode
if data.FirstStageMode == "" {
data.FirstStageMode = "custom"
}
// Persona lookup deferred — personas are extensions now
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow-landing.html", PageData{
Surface: "workflow-landing",
InstanceName: instanceName,
Data: data,
})
}
}
// getUserContext extracts user info from gin context (set by auth middleware)
// and enriches it with display name, username, and email from the database.
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
userID, exists := c.Get("user_id")
if !exists {
return nil
}
uid := userID.(string)
uc := &UserContext{
ID: uid,
Email: c.GetString("email"),
Role: c.GetString("role"),
}
// Enrich from DB — username, display_name, and email may not be in JWT claims.
if e.stores.Users != nil {
if u, err := e.stores.Users.GetByID(c.Request.Context(), uid); err == nil && u != nil {
uc.Username = u.Username
uc.DisplayName = u.DisplayName
if uc.Email == "" {
uc.Email = u.Email
}
}
}
return uc
}
// loadBanner reads banner config from global settings.
func (e *Engine) loadBanner() BannerConfig {
if e.stores.GlobalConfig == nil {
return BannerConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "banner")
if err != nil || raw == nil {
return BannerConfig{}
}
b := BannerConfig{}
if v, ok := raw["enabled"].(bool); ok {
b.Visible = v
}
if v, ok := raw["text"].(string); ok {
b.Text = v
}
if v, ok := raw["fg"].(string); ok {
b.Color = v
}
if v, ok := raw["bg"].(string); ok {
b.Background = v
}
return b
}
// loadMessage reads dismissible message bar config from global settings.
func (e *Engine) loadMessage() MessageConfig {
if e.stores.GlobalConfig == nil {
return MessageConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "message")
if err != nil || raw == nil {
return MessageConfig{}
}
m := MessageConfig{}
if v, ok := raw["enabled"].(bool); ok {
m.Visible = v
}
if v, ok := raw["text"].(string); ok {
m.Text = v
}
if v, ok := raw["variant"].(string); ok {
m.Variant = v
}
if m.Variant == "" {
m.Variant = "info"
}
return m
}
// loadFooter reads optional footer bar config from global settings.
func (e *Engine) loadFooter() FooterConfig {
if e.stores.GlobalConfig == nil {
return FooterConfig{}
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "footer")
if err != nil || raw == nil {
return FooterConfig{}
}
f := FooterConfig{}
if v, ok := raw["enabled"].(bool); ok {
f.Visible = v
}
if v, ok := raw["text"].(string); ok {
f.Text = v
}
return f
}
// loadBranding reads instance branding from global settings.
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
name = "Armature" // default
if e.stores.GlobalConfig == nil {
return
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding")
if err != nil || raw == nil {
return
}
if v, ok := raw["instance_name"].(string); ok && v != "" {
name = v
}
if v, ok := raw["logo_url"].(string); ok {
logoURL = v
}
if v, ok := raw["tagline"].(string); ok {
tagline = v
}
return
}
// isRegistrationOpen checks if self-registration is enabled.
func (e *Engine) isRegistrationOpen() bool {
if e.stores.GlobalConfig == nil {
return false
}
raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration")
if err != nil || raw == nil {
return false
}
if v, ok := raw["enabled"].(bool); ok {
return v
}
return false
}
// Version is injected at build time via ldflags.
var Version = "dev"
// SetVersion allows main.go to inject the build version.
func SetVersion(v string) {
Version = v
}
// WriteError writes an error page directly to the response.
func (e *Engine) WriteError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Error</title></head><body><h1>%d</h1><p>%s</p></body></html>`, status, template.HTMLEscapeString(msg))
}
// ── Template FuncMap helpers ─────────────────
func toJSON(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b)
}
func stringContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// dict creates a map from alternating key/value pairs for template use.
func dict(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs)-1; i += 2 {
key, ok := pairs[i].(string)
if !ok {
continue
}
m[key] = pairs[i+1]
}
return m
}
// tmplOr returns the first non-empty string argument.
func tmplOr(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "fallback-nonce"
}
return hex.EncodeToString(b)
}