Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
24 KiB
Script Generator Phase 2 — Frontend Design
Date: 2026-03-13
Status: Approved
Phase: 2 of Script Generator feature
Builds on: Phase 1 backend (feat/script-generator PR #105)
Goal
Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.
Architecture
Layout: Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
State: Zustand store (scriptGeneratorStore) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. reset() is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; selectTemplate() clears form/output state inline via its own atomic set() call without calling reset().
Preview: Client-side lightweight substitution for live preview as the user types ({{key}} replacement only, matching the backend template variable syntax). The real POST /scripts/generate endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend.
Security note: The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend POST /scripts/generate endpoint uses only get_current_active_user with no role check. Adding a backend engineer role guard is deferred — this is a known limitation.
Search note: The backend ?search= parameter matches against name, description, and slug. Tags are filtered separately via ?tags= (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only.
Zustand Store — scriptGeneratorStore
File: frontend/src/store/scriptGeneratorStore.ts
State shape
interface ScriptGeneratorState {
// Template browsing
categories: ScriptCategoryResponse[];
templates: ScriptTemplateListItem[];
selectedTemplate: ScriptTemplateDetail | null;
searchQuery: string;
activeCategoryId: string | null; // null = "All"; used to derive slug for API calls
isLoadingTemplates: boolean; // drives skeleton in ScriptTemplateList only
isLoadingDetail: boolean; // drives spinner in ScriptGeneratorPanel only
// Form
paramValues: Record<string, string>; // keyed by ScriptParameter.key; booleans stored as 'true'/'false'
formErrors: Record<string, string>; // keyed by ScriptParameter.key
// Output
generatedScript: string | null;
generationId: string | null;
generationWarnings: string[];
isGenerating: boolean;
generateError: string | null;
// Actions
loadCategories: () => Promise<void>;
loadTemplates: () => Promise<void>;
selectTemplate: (id: string) => Promise<void>;
setCategory: (id: string | null) => void;
setSearch: (query: string) => void;
setParamValue: (key: string, value: string) => void;
validate: () => boolean;
generate: (sessionId?: string) => Promise<void>;
clearOutput: () => void;
reset: () => void;
}
Behaviour notes
setCategoryupdatesactiveCategoryId, then callsloadTemplates()setSearchupdatessearchQuery, then callsloadTemplates(). The component (not the store) debounces its call tosetSearch—setSearchalways callsloadTemplates()immediately on invocationloadTemplates()resolves the slug from thecategoriesarray (categories.find(c => c.id === activeCategoryId)?.slug) before sending{ category_slug, search }to the API. WhenactiveCategoryIdisnull("All"),category_slugis omitted from the request. Prerequisite:loadCategories()must complete beforeloadTemplates()orsetCategory()can resolve slugs — the page bootstrap calls them in this orderselectTemplate(id)workflow:- Sets
isLoadingDetail: true(does NOT touchisLoadingTemplates) - Fetches
ScriptTemplateDetailfrom the API - In a single
set()call: setsselectedTemplate, populatesparamValuesby converting eachparameter.defaultto string (null→'',true→'true',false→'false', numbers →String(n)), clearsformErrors,generatedScript,generationId,generationWarnings,generateError, setsisLoadingDetail: false - The template list remains fully visible and interactive throughout
- Sets
reset()clears exactly:paramValues,formErrors,generatedScript,generationId,generationWarnings,generateError. Does NOT clearselectedTemplate,categories,templates, or any browsing state. Not called byselectTemplate()— that action handles its own inline clear. Exposed as a public store action for Phase 3 callersvalidate(): ifselectedTemplateisnull, returnstrueimmediately (nothing to validate). Otherwise iterates(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters(cast required — backend typesparameters_schemaasdict; seeScriptTemplateDetailtype comment), checksrequired && !paramValues[key]for each, writes errors toformErrorsviaset(), returnsfalseif any required param is missing,trueotherwise. Client-side validation ofScriptParameterValidationfields (pattern,min_length,max_length,min_value,max_value) is NOT implemented in Phase 2 — only required-field presence is checked. The backend validates these constraints onPOST /scripts/generateand returns errors indetail. Phase 2 does not render per-field errors from the backend — all backend validation errors surface as a singlegenerateErrorbelow the action bargenerate(sessionId?): ifselectedTemplateisnull, returns immediately (no-op). Otherwise callsvalidate()first — if it returnsfalse, stops (errors are already in store). If valid, setsisGenerating: true, clearsgenerateError, callsPOST /scripts/generate. On success: setsgeneratedScript/generationId/generationWarnings, setsisGenerating: false. On error: extractserror.response?.data?.detail(FastAPI detail string) or falls back to'Failed to generate script', setsgenerateError, setsisGenerating: false- On generate success:
generatedScript=response.script,generationId=response.id,generationWarnings=response.warnings
Types
Added to frontend/src/types/index.ts:
export interface ScriptCategoryResponse {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
sort_order: number;
template_count: number;
}
export interface ScriptTemplateListItem {
id: string;
category_id: string;
team_id: string | null;
name: string;
slug: string;
description: string | null;
tags: string[];
complexity: 'beginner' | 'intermediate' | 'advanced'; // must match backend ScriptComplexity enum exactly
estimated_runtime: string | null;
requires_elevation: boolean;
requires_modules: string[];
is_verified: boolean;
usage_count: number;
}
export interface ScriptParameterOption {
value: string;
label: string;
}
export interface ScriptParameterValidation {
min_value?: number; // matches backend field name (not 'min')
max_value?: number; // matches backend field name (not 'max')
pattern?: string;
min_length?: number;
max_length?: number;
}
export interface ScriptParameter {
key: string;
label: string;
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
required: boolean;
placeholder: string | null;
group: string | null;
order: number;
help_text: string | null;
options: ScriptParameterOption[] | null; // for select type: [{value, label}]
default: string | boolean | number | null;
validation: ScriptParameterValidation | null;
sensitive: boolean;
}
export interface ScriptParametersSchema {
parameters: ScriptParameter[];
}
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
use_case: string | null;
script_body: string;
// NOTE: backend types this as `dict` — FastAPI serializes it as plain JSON.
// At runtime this arrives as `unknown`. Access via a helper:
// function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] {
// return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? [])
// }
parameters_schema: ScriptParametersSchema;
default_values: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
validation_rules: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
version: number;
created_at: string;
updated_at: string;
}
export interface ScriptGenerateRequest {
template_id: string;
parameters: Record<string, unknown>;
session_id?: string;
}
export interface ScriptGenerateResponse {
id: string; // generation UUID
script: string; // rendered PowerShell
warnings: string[];
metadata: {
template_name: string;
template_version: number;
requires_elevation: boolean;
[key: string]: unknown;
};
}
export interface ScriptGenerationRecord {
id: string;
template_id: string;
template_name: string;
parameters_used: Record<string, unknown>; // sensitive values already redacted by backend
created_at: string;
}
API Client
File: frontend/src/api/scripts.ts — use a named export object (matching the copilotApi/assistantChatApi pattern in api/index.ts):
// scripts.ts
export const scriptsApi = {
getCategories(): Promise<ScriptCategoryResponse[]> { ... },
getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise<ScriptTemplateListItem[]> { ... },
getTemplateDetail(id: string): Promise<ScriptTemplateDetail> { ... },
generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> { ... },
getGenerations(): Promise<ScriptGenerationRecord[]> { ... },
}
Re-exported from frontend/src/api/index.ts as:
export { scriptsApi } from './scripts'
All methods use the existing apiClient (base URL /api/v1, auth interceptor handles token refresh).
getGenerations()and thetagsparam ongetTemplatesare Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a// Phase 3comment in the implementation.
Component Tree
ScriptLibraryPage pages/ScriptLibraryPage.tsx
├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx
├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx
│ └── TemplateCard components/scripts/TemplateCard.tsx
└── ScriptGeneratorPanel components/scripts/ScriptGeneratorPanel.tsx
├── ScriptParameterForm components/scripts/ScriptParameterForm.tsx
│ └── ScriptParameterField components/scripts/ScriptParameterField.tsx
└── ScriptPreview components/scripts/ScriptPreview.tsx
└── PowerShellHighlighter components/scripts/PowerShellHighlighter.tsx
Component responsibilities
ScriptLibraryPage
- Bootstraps store on mount: calls
loadCategories()thenloadTemplates() - Owns
inputValuestate (search input text) andonClearSearchcallback — lifted here soScriptFilterBarandScriptTemplateListcan coordinate clear-search without direct coupling - Renders
ScriptFilterBar(passinginputValue,setInputValue,onClearSearch) above the two columns - Renders two-column layout:
ScriptTemplateList(left, passinginputValue+onClearSearch) andScriptGeneratorPanel(right) - Minimal logic — only the search state lift, everything else in the store or child components
ScriptFilterBar
- Renders an "All" tab first (calls
setCategory(null), active whenactiveCategoryId === null), followed by one tab per category - Category tabs: pill style,
bg-primary/10+ left 3px cyan accent bar on active tab - Receives
inputValue: stringandsetInputValue: (v: string) => voidas props fromScriptLibraryPage(state is lifted —ScriptFilterBardoes NOT own local search state) <Input>is controlled byinputValueprop.useEffectinsideScriptFilterBarwatchesinputValue, schedulessetTimeout(() => setSearch(inputValue), 300), clears timeout on cleanup — debounce lives here but the value lives in the page- Reads
categories,activeCategoryIdfrom store
ScriptTemplateList
- Scrollable list of
TemplateCardcomponents - Reads
templates,isLoadingTemplates,selectedTemplatefrom store - Accepts
inputValue: stringandonClearSearch: () => voidprops fromScriptLibraryPage - Shows 3 skeleton placeholder cards while
isLoadingTemplatesis true - Shows "No templates found" empty state when
templates.length === 0and!isLoadingTemplatesandinputValue === '' - Shows "No templates match your search" + "Clear search" button when
templates.length === 0and!isLoadingTemplatesandinputValue !== ''. "Clear search" callsonClearSearch()— a callback prop fromScriptLibraryPagedefined as() => { setInputValue(''); store.setSearch(''); }. UsinginputValue(notstore.searchQuery) avoids the 300ms debounce lag in empty-state detection
TemplateCard
- Displays: name,
complexitybadge,usage_count, description (2-line clamp vialine-clamp-2),tags - Shows a
requires_elevationwarning icon (LucideShieldAlert, amber-400) if true - Active state when
template.id === selectedTemplate?.id:bg-primary/10background + 3px left cyan gradient accent bar - Calls
selectTemplate(template.id)on click - Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
- Slug is already URL/filesystem-safe by backend convention — no sanitization needed
ScriptGeneratorPanel
- Shows placeholder (Terminal icon + "Select a template to get started") when
selectedTemplateis null - Shows a full-panel centered spinner when
isLoadingDetailis true, replacing the panel content (not an overlay — no prior template content shown while loading). The template list column remains fully visible and interactive - When template selected and
!isLoadingDetail: renders template name/description header,ScriptParameterForm,ScriptPreview, action bar (in that order, top to bottom) - Visual order within the panel (top to bottom): warnings callout →
ScriptPreview→ action bar → error text generationWarningsshown as amber-400 callout above the preview whengenerationWarnings.length > 0- Action bar (below preview):
- Generate button (
bg-gradient-brand) — callsgenerate()with no arguments (Phase 3 will passsessionId); disabled whenisGeneratingOR!canGenerate; shows spinner whileisGenerating - Download
.ps1button — disabled whengeneratedScriptis null OR!canGenerate; on click triggersnew Blob([generatedScript], { type: 'text/plain' })→ programmatic anchor click withdownload="${selectedTemplate.slug}.ps1"
- Generate button (
generateErrorshown as rose-500 text inline below action bar- Permission check: call
usePermissions()directly in this component; derivecanGenerate = isEngineer. PasscanGenerateas a prop down toScriptParameterForm. Generate and Download buttons disabled with tooltip "Engineer access required" when!canGenerate
ScriptParameterForm
- Accepts
canGenerate: booleanprop fromScriptGeneratorPanel - Iterates
selectedTemplate.parameters_schema.parameterssorted byorder. Access via cast:(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters—parameters_schemaarrives asdictat runtime (backend types it asdict; helper comment inScriptTemplateDetailtype definition) - Groups by
groupfield: renders afont-label uppercase text-muted-foregroundsection label before each group boundary (parameters withgroup: nullrendered ungrouped at the top) - Renders a
ScriptParameterFieldper parameter, passingdisabled={!canGenerate}(convertscanGenerateboolean to thedisabledprop expected byScriptParameterField) - Does NOT own the Generate button and does NOT call
generate()orvalidate()directly — validation is triggered fromScriptGeneratorPanelvia the store'sgenerate()action (which callsvalidate()internally)
ScriptParameterField
- Accepts
param: ScriptParameter,value: string,error: string | undefined,disabled: boolean - Renders input by
type. Two error rendering approaches depending on type:- Shared error rendering — pass
errorprop to the shared component; it renders its own error message (do NOT add a separate<p>below):text→<Input error={error} />password→<Input type="password" error={error} />with LucideEye/EyeOfftoggletextarea→<Textarea error={error} />number→<Input type="number" error={error} />multi_text→<Input placeholder="Comma-separated values" error={error} />; stored as single string, backend splits on comma
- Manual error rendering — no shared component; render error explicitly as
<p className="mt-1.5 text-xs text-red-400">{error}</p>below the input:select→ native<select className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]" disabled={disabled}>; options fromparameter.optionsboolean→<input type="checkbox" className="rounded border-border" checked={value === 'true'} onChange={e => setParamValue(key, e.target.checked ? 'true' : 'false')} disabled={disabled} />
- Shared error rendering — pass
- Shows
help_textas<p className="text-xs text-muted-foreground mt-1">below the input (before the error) - Calls
setParamValue(key, value)on every change event; boolean usese.target.checked ? 'true' : 'false'
ScriptPreview
- Two modes determined by
generatedScriptin store:- Draft mode (
generatedScript === null): client-side{{key}}substitution inselectedTemplate.script_body. Sensitive params (parameter.sensitive === true) rendered as exactly four asterisks****regardless ofparamValuesvalue. Unfilled non-sensitive params left as{{key}}forPowerShellHighlighterto color amber - Generated mode (
generatedScript !== null): showsgeneratedScript
- Draft mode (
- The component computes
displayScriptas a local variable (the substituted preview string in draft mode, orgeneratedScriptin generated mode). Both the render and the copy handler reference this same local variable — no separate store field needed - Copy icon (Lucide
Copy, 14px) in top-right corner of code block, visible in both modes:- On click: calls
navigator.clipboard.writeText(displayScript). On success: shows "Copied!" for 2s then resets. On failure: silently fails — no error displayed, icon does not change state
- On click: calls
- Passes
displayScripttoPowerShellHighlighter
PowerShellHighlighter
-
Pure component:
({ script: string }) => JSX.Element -
Single-pass tokenizer using a combined alternation regex (not sequential replace). The regex alternation matches tokens in priority order; each match is replaced with a
<span>and the remaining string continues from after the match. This prevents a variable inside a string literal from being re-colored by the variable rule:Priority order in alternation: 1. Comments: /#[^\r\n]*/ 2. String literals: /"[^"]*"|'[^']*'/ 3. Unfilled placeholders: /\{\{[^}]+\}\}/ 4. Variables: /\$\w+/ 5. Cmdlets: /[A-Z][a-z]+-[A-Z][a-zA-Z]+/ 6. Parameters: /-[A-Za-z]+/ 7. Keywords: /\b(if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/Note: variables (priority 4) consume tokens like
$foreachbefore keywords (priority 7) can match — this is intentional. Do not reorder the alternation without reviewing this interaction.Token colors:
- Comments →
text-[#8b949e] - String literals →
text-[#a5d6ff] - Unfilled placeholders →
text-amber-400 underline decoration-dashed - Variables →
text-[#79c0ff] - Cmdlets →
text-[#22d3ee] - Parameters →
text-[#d2a8ff] - Keywords →
text-[#ff7b72] - Unmatched text → unstyled
- Comments →
-
Renders as
<pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"><code>
Routing & Navigation
- Route:
/scriptsadded tofrontend/src/router.tsxinside theProtectedRoute/AppLayoutchildren - Sidebar nav entry: "Scripts" with a
Terminalicon (Lucide), added toAppLayout.tsxin the main nav group - No sub-routes needed for Phase 2
Permissions
| Action | Minimum role |
|---|---|
| View Script Library page | Any authenticated user |
| Browse templates, see draft preview | Any authenticated user |
| Fill form, generate, copy, download | Engineer, owner, or super_admin |
usePermissions() is called once in ScriptGeneratorPanel. Derive canGenerate = isEngineer (usePermissions().isEngineer returns true for engineer, owner, and super_admin via the role hierarchy check — equivalent to !isViewer given the four-role system, but isEngineer is more semantically explicit). Pass canGenerate as a prop down to ScriptParameterForm. Leaf components (ScriptParameterField) receive disabled as a prop — they do not call usePermissions() directly.
Viewer-blocking is frontend-only in Phase 2 (backend role guard deferred — known limitation, documented in Architecture section).
Tooltip implementation: Use the HTML title attribute on the wrapper <span> around disabled buttons — consistent with the existing codebase pattern (e.g. TopBar.tsx, NavItem.tsx, CheckoutButton.tsx). Example: <span title="Engineer access required"><button disabled ...>Generate</button></span>.
Search Behaviour
- Search query sent as
?search=toGET /scripts/templates; matchesname,description,slug(not tags) - 300ms debounce in
ScriptFilterBar: localinputValuestate,useEffect+setTimeout/clearTimeout setSearchis called once after the debounce delay; it immediately callsloadTemplates()- Category filter and search compose:
loadTemplates()sends bothcategory_slugandsearchsimultaneously inputValueis owned byScriptLibraryPage(lifted state).ScriptLibraryPagepasses it as a prop toScriptFilterBar(which controls the<Input>) and toScriptTemplateList(which uses it to choose the correct empty-state variant).onClearSearch— also defined inScriptLibraryPageas() => { setInputValue(''); store.setSearch(''); }— is passed toScriptTemplateListfor the "Clear search" button
Empty & Loading States
| Scenario | Treatment |
|---|---|
| Templates loading | 3 skeleton TemplateCard placeholders (isLoadingTemplates) |
| No templates in category | Empty state icon + "No templates found" |
| No search results | "No templates match your search" + "Clear search" button |
| Detail fetch in progress | Spinner in ScriptGeneratorPanel (isLoadingDetail); template list stays visible |
| No template selected | Right panel: Terminal icon + "Select a template to get started" |
| Generating | Generate button spinner + disabled (isGenerating) |
| Generate error | Rose-500 inline text below action bar |
| Warnings after generate | Amber-400 callout above preview, one line per warning |
Out of Scope (Phase 2)
- Session-embedded script generation (Script Output Node) — Phase 3
- Tag filter UI — deferred (backend capability exists via
?tags=) - Backend engineer role guard on generate endpoint — deferred (known gap)
- Template creation/editing UI — admin-only, deferred
- Generation history page — deferred
- Admin template management — deferred
- Script execution / RMM integration — long-term roadmap