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>
1033 lines
30 KiB
Go
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)
|
|
}
|