feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector

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>
This commit was merged in pull request #105.
This commit is contained in:
chihlasm
2026-03-14 20:18:59 -04:00
committed by GitHub
parent 83b13fcd26
commit d4dbf44781
50 changed files with 11916 additions and 11 deletions

View File

@@ -0,0 +1,317 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, GripVertical, Trash2, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import type { ScriptParameter, ScriptParameterOption, ScriptParameterValidation } from '@/types'
const PARAM_TYPES = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
] as const
interface Props {
param: ScriptParameter
index: number
onChange: (index: number, updated: ScriptParameter) => void
onRemove: (index: number) => void
onMoveUp: (index: number) => void
onMoveDown: (index: number) => void
isFirst: boolean
isLast: boolean
disabled?: boolean
}
export function ParameterCard({
param, index, onChange, onRemove, onMoveUp, onMoveDown, isFirst, isLast, disabled,
}: Props) {
const [expanded, setExpanded] = useState(true)
const update = (patch: Partial<ScriptParameter>) => {
onChange(index, { ...param, ...patch })
}
const updateOption = (optIndex: number, patch: Partial<ScriptParameterOption>) => {
const options = [...(param.options ?? [])]
options[optIndex] = { ...options[optIndex], ...patch }
update({ options })
}
const addOption = () => {
const options = [...(param.options ?? []), { value: '', label: '' }]
update({ options })
}
const removeOption = (optIndex: number) => {
const options = (param.options ?? []).filter((_, i) => i !== optIndex)
update({ options })
}
const updateValidation = (patch: Partial<ScriptParameterValidation>) => {
update({ validation: { ...(param.validation ?? {}), ...patch } })
}
return (
<div className="border border-border rounded-xl overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
>
<GripVertical size={14} className="text-muted-foreground/50 shrink-0" />
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
<span className="text-sm font-medium text-foreground flex-1 text-left">
{param.label || param.key || `Parameter ${index + 1}`}
</span>
<span className="font-label text-[0.625rem] text-muted-foreground uppercase">{param.type}</span>
{param.required && <span className="text-red-400 text-xs">*</span>}
</button>
{/* Body */}
{expanded && (
<div className="px-3 py-3 space-y-3 border-t border-border">
{/* Row 1: key + label */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key (used in &#123;&#123;key&#125;&#125;)</label>
<Input
value={param.key}
onChange={e => update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="param_key"
disabled={disabled}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={param.label}
onChange={e => update({ label: e.target.value })}
placeholder="Display Label"
disabled={disabled}
/>
</div>
</div>
{/* Row 2: type + group */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Type</label>
<select
value={param.type}
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
disabled={disabled}
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:cursor-not-allowed disabled:opacity-50"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Group (optional)</label>
<Input
value={param.group ?? ''}
onChange={e => update({ group: e.target.value || null })}
placeholder="e.g. User Identity"
disabled={disabled}
/>
</div>
</div>
{/* Row 3: placeholder + help text */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Placeholder</label>
<Input
value={param.placeholder ?? ''}
onChange={e => update({ placeholder: e.target.value || null })}
placeholder="Placeholder text"
disabled={disabled}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Help text</label>
<Input
value={param.help_text ?? ''}
onChange={e => update({ help_text: e.target.value || null })}
placeholder="Help text shown below field"
disabled={disabled}
/>
</div>
</div>
{/* Row 4: toggles */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={param.required}
onChange={e => update({ required: e.target.checked })}
disabled={disabled}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={param.sensitive}
onChange={e => update({ sensitive: e.target.checked })}
disabled={disabled}
className="rounded border-border"
/>
Sensitive (redacted in logs)
</label>
</div>
{/* Default value */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={param.default !== null && param.default !== undefined ? String(param.default) : ''}
onChange={e => update({ default: e.target.value || null })}
placeholder="Default value"
disabled={disabled}
/>
</div>
{/* Select options (only for select type) */}
{param.type === 'select' && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Options</label>
<div className="space-y-1.5">
{(param.options ?? []).map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt.value}
onChange={e => updateOption(i, { value: e.target.value })}
placeholder="value"
disabled={disabled}
/>
<Input
value={opt.label}
onChange={e => updateOption(i, { label: e.target.value })}
placeholder="label"
disabled={disabled}
/>
<button
type="button"
onClick={() => removeOption(i)}
disabled={disabled}
className="p-1 text-muted-foreground hover:text-rose-500 transition-colors"
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addOption}
disabled={disabled}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus size={12} /> Add option
</button>
</div>
</div>
)}
{/* Validation (for text/number types) */}
{(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Validation (optional)</label>
<div className="grid grid-cols-3 gap-2">
{param.type === 'number' ? (
<>
<div>
<label className="text-[0.625rem] text-muted-foreground">Min value</label>
<Input
type="number"
value={param.validation?.min_value ?? ''}
onChange={e => updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
<div>
<label className="text-[0.625rem] text-muted-foreground">Max value</label>
<Input
type="number"
value={param.validation?.max_value ?? ''}
onChange={e => updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
</>
) : (
<>
<div>
<label className="text-[0.625rem] text-muted-foreground">Min length</label>
<Input
type="number"
value={param.validation?.min_length ?? ''}
onChange={e => updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
<div>
<label className="text-[0.625rem] text-muted-foreground">Max length</label>
<Input
type="number"
value={param.validation?.max_length ?? ''}
onChange={e => updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
</>
)}
<div>
<label className="text-[0.625rem] text-muted-foreground">Pattern (regex)</label>
<Input
value={param.validation?.pattern ?? ''}
onChange={e => updateValidation({ pattern: e.target.value || undefined })}
placeholder="^[a-z]+$"
disabled={disabled}
/>
</div>
</div>
</div>
)}
{/* Actions row */}
<div className="flex items-center justify-between pt-1 border-t border-border">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onMoveUp(index)}
disabled={isFirst || disabled}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
>
Up
</button>
<button
type="button"
onClick={() => onMoveDown(index)}
disabled={isLast || disabled}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
>
Down
</button>
</div>
<button
type="button"
onClick={() => onRemove(index)}
disabled={disabled}
className="flex items-center gap-1 text-xs text-rose-500 hover:text-rose-400 transition-colors px-1.5 py-0.5"
>
<Trash2 size={12} /> Remove
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
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)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react'
import { Plus, Code, List } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ParameterCard } from './ParameterCard'
import type { ScriptParameter, ScriptParametersSchema } from '@/types'
interface Props {
schema: ScriptParametersSchema
onChange: (schema: ScriptParametersSchema) => void
disabled?: boolean
}
function newParameter(order: number): ScriptParameter {
return {
key: '',
label: '',
type: 'text',
required: true,
placeholder: null,
group: null,
order,
help_text: null,
options: null,
default: null,
validation: null,
sensitive: false,
}
}
export function ParameterSchemaBuilder({ schema, onChange, disabled }: Props) {
const [mode, setMode] = useState<'visual' | 'json'>('visual')
const [jsonText, setJsonText] = useState('')
const [jsonError, setJsonError] = useState<string | null>(null)
const parameters = schema.parameters ?? []
const updateParams = (params: ScriptParameter[]) => {
onChange({ parameters: params })
}
const handleParamChange = (index: number, updated: ScriptParameter) => {
const next = [...parameters]
next[index] = updated
updateParams(next)
}
const handleRemove = (index: number) => {
updateParams(parameters.filter((_, i) => i !== index))
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const next = [...parameters]
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
next.forEach((p, i) => { p.order = i + 1 })
updateParams(next)
}
const handleMoveDown = (index: number) => {
if (index === parameters.length - 1) return
const next = [...parameters]
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
next.forEach((p, i) => { p.order = i + 1 })
updateParams(next)
}
const handleAdd = () => {
updateParams([...parameters, newParameter(parameters.length + 1)])
}
const switchToJson = () => {
setJsonText(JSON.stringify(schema, null, 2))
setJsonError(null)
setMode('json')
}
const switchToVisual = () => {
try {
const parsed = JSON.parse(jsonText)
if (!parsed.parameters || !Array.isArray(parsed.parameters)) {
setJsonError('JSON must have a "parameters" array')
return
}
onChange(parsed as ScriptParametersSchema)
setJsonError(null)
setMode('visual')
} catch (e) {
setJsonError(`Invalid JSON: ${(e as Error).message}`)
}
}
return (
<div className="flex flex-col gap-3">
{/* Mode toggle */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => mode === 'json' ? switchToVisual() : undefined}
className={cn(
'flex items-center gap-1.5 font-label text-xs px-3 py-1.5 rounded-full border transition-all',
mode === 'visual'
? 'bg-primary/10 border-primary/30 text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
)}
>
<List size={12} /> Visual
</button>
<button
type="button"
onClick={() => mode === 'visual' ? switchToJson() : undefined}
className={cn(
'flex items-center gap-1.5 font-label text-xs px-3 py-1.5 rounded-full border transition-all',
mode === 'json'
? 'bg-primary/10 border-primary/30 text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
)}
>
<Code size={12} /> JSON
</button>
</div>
{mode === 'visual' ? (
<>
{parameters.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No parameters defined. Add one to create dynamic form fields.
</p>
) : (
<div className="flex flex-col gap-2">
{parameters.map((param, i) => (
<ParameterCard
key={i}
param={param}
index={i}
onChange={handleParamChange}
onRemove={handleRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
isFirst={i === 0}
isLast={i === parameters.length - 1}
disabled={disabled}
/>
))}
</div>
)}
<button
type="button"
onClick={handleAdd}
disabled={disabled}
className="flex items-center gap-1.5 text-sm text-primary hover:underline self-start"
>
<Plus size={14} /> Add Parameter
</button>
</>
) : (
<>
<textarea
value={jsonText}
onChange={e => { setJsonText(e.target.value); setJsonError(null) }}
disabled={disabled}
spellCheck={false}
className="w-full min-h-[300px] resize-y font-label text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
placeholder='{ "parameters": [...] }'
/>
{jsonError && (
<p className="text-xs text-rose-500">{jsonError}</p>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react'
import Editor, { type BeforeMount } from '@monaco-editor/react'
import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme'
import { Spinner } from '@/components/common/Spinner'
interface Props {
value: string
onChange: (value: string) => void
disabled?: boolean
}
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
// Register our dark theme if not already defined
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
}, [])
return (
<div className="rounded-xl border border-border overflow-hidden">
<Editor
height="300px"
language="powershell"
theme={THEME_ID}
value={value}
onChange={v => onChange(v ?? '')}
beforeMount={handleBeforeMount}
loading={
<div className="flex h-[300px] items-center justify-center bg-card">
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
</div>
}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "'JetBrains Mono', monospace",
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
tabSize: 4,
insertSpaces: true,
automaticLayout: true,
readOnly: disabled,
padding: { top: 12, bottom: 12 },
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
}}
/>
</div>
)
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect } from 'react'
import { ArrowLeft, Loader2, Save, Scan, Trash2 } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { usePermissions } from '@/hooks/usePermissions'
import { scriptsApi } from '@/api'
import { ScriptBodyEditor } from './ScriptBodyEditor'
import { ParameterSchemaBuilder } from './ParameterSchemaBuilder'
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type {
ScriptTemplateDetail,
ScriptCategoryResponse,
ScriptParametersSchema,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
ParameterCandidate,
ScriptParameter,
} from '@/types'
interface Props {
templateId: string | null // null = create mode
onBack: () => void
onSaved: () => void
}
interface FormState {
name: string
description: string
use_case: string
category_id: string
complexity: 'beginner' | 'intermediate' | 'advanced'
tags: string
estimated_runtime: string
requires_elevation: boolean
requires_modules: string
script_body: string
parameters_schema: ScriptParametersSchema
}
const EMPTY_FORM: FormState = {
name: '',
description: '',
use_case: '',
category_id: '',
complexity: 'beginner',
tags: '',
estimated_runtime: '',
requires_elevation: false,
requires_modules: '',
script_body: '',
parameters_schema: { parameters: [] },
}
export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isLoading, setIsLoading] = useState(!!templateId)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
const { canShareScriptTemplate } = usePermissions()
// Dismiss stepper if user edits the script body during detection
const scriptBodyRef = form.script_body
useEffect(() => {
if (showStepper) {
setShowStepper(false)
setDetectedCandidates([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scriptBodyRef])
// Load categories + template detail (if editing)
useEffect(() => {
const load = async () => {
try {
const cats = await scriptsApi.getCategories()
setCategories(cats)
if (templateId) {
const detail = await scriptsApi.getTemplateDetail(templateId)
setTemplate(detail)
const schema = detail.parameters_schema as ScriptParametersSchema
setForm({
name: detail.name,
description: detail.description ?? '',
use_case: detail.use_case ?? '',
category_id: detail.category_id,
complexity: detail.complexity,
tags: detail.tags.join(', '),
estimated_runtime: detail.estimated_runtime ?? '',
requires_elevation: detail.requires_elevation,
requires_modules: detail.requires_modules.join(', '),
script_body: detail.script_body,
parameters_schema: schema ?? { parameters: [] },
})
} else if (cats.length > 0) {
setForm(f => ({ ...f, category_id: cats[0].id }))
}
} catch {
setSaveError('Failed to load data')
} finally {
setIsLoading(false)
}
}
load()
}, [templateId])
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm(f => ({ ...f, [key]: value }))
setIsDirty(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
setSaveError('Name is required')
return
}
if (!form.script_body.trim()) {
setSaveError('Script body is required')
return
}
if (!form.category_id) {
setSaveError('Category is required')
return
}
setIsSaving(true)
setSaveError(null)
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean)
const requires_modules = form.requires_modules.split(',').map(m => m.trim()).filter(Boolean)
try {
if (templateId) {
const data: ScriptTemplateUpdateRequest = {
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.updateTemplate(templateId, data)
} else {
const data: ScriptTemplateCreateRequest = {
category_id: form.category_id,
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.createTemplate(data)
}
setIsDirty(false)
onSaved()
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setSaveError(axiosErr.response?.data?.detail ?? 'Failed to save template')
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!templateId) return
try {
await scriptsApi.deleteTemplate(templateId)
onSaved()
} catch {
setSaveError('Failed to delete template')
}
}
const handleShare = async (shared: boolean) => {
if (!templateId) return
try {
const updated = await scriptsApi.shareTemplate(templateId, shared)
setTemplate(updated)
} catch {
setSaveError('Failed to update sharing')
}
}
const handleBack = () => {
if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) return
onBack()
}
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
)
}
return (
<div className="flex flex-col gap-6 pb-24">
{/* Back link */}
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<ArrowLeft size={12} />
Back to templates
</button>
<h1 className="text-2xl font-heading font-bold text-foreground">
{templateId ? 'Edit Template' : 'New Template'}
</h1>
{/* ── Metadata ──────────────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-4">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Metadata</p>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Name <span className="text-red-400">*</span>
</label>
<Input
value={form.name}
onChange={e => updateField('name', e.target.value)}
placeholder="e.g. Create AD User"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Description</label>
<Textarea
value={form.description}
onChange={e => updateField('description', e.target.value)}
placeholder="What does this script do?"
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Use Case</label>
<Textarea
value={form.use_case}
onChange={e => updateField('use_case', e.target.value)}
placeholder="When would you use this?"
rows={3}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Category <span className="text-red-400">*</span>
</label>
<select
value={form.category_id}
onChange={e => updateField('category_id', e.target.value)}
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)]"
>
<option value="">Select category</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Complexity</label>
<select
value={form.complexity}
onChange={e => updateField('complexity', e.target.value as FormState['complexity'])}
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)]"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Estimated Runtime</label>
<Input
value={form.estimated_runtime}
onChange={e => updateField('estimated_runtime', e.target.value)}
placeholder="e.g. 30 seconds"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Tags (comma-separated)</label>
<Input
value={form.tags}
onChange={e => updateField('tags', e.target.value)}
placeholder="active-directory, user, onboarding"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Required Modules (comma-separated)</label>
<Input
value={form.requires_modules}
onChange={e => updateField('requires_modules', e.target.value)}
placeholder="ActiveDirectory, GroupPolicy"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={form.requires_elevation}
onChange={e => updateField('requires_elevation', e.target.checked)}
className="rounded border-border"
/>
Requires elevation (Run as Administrator)
</label>
{/* Share toggle — only for owners/admins editing an existing template */}
{templateId && template && canShareScriptTemplate && (
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={template.team_id !== null}
onChange={e => handleShare(e.target.checked)}
className="rounded border-border"
/>
Share with team
<span className="text-xs text-muted-foreground">(visible to all team members)</span>
</label>
)}
</div>
</section>
{/* ── Script Body ───────────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-3">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Script Body <span className="text-red-400">*</span>
</p>
<p className="text-xs text-muted-foreground">
Use <code className="font-label text-amber-400">{'{{param_key}}'}</code> for parameter placeholders.
Supports <code className="font-label text-amber-400">{'{% if param %} ... {% endif %}'}</code> conditionals
and filters like <code className="font-label text-amber-400">{'{{ param | as_secure_string }}'}</code>.
</p>
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
</section>
{/* ── Parameters Schema ─────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-3">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Parameters</p>
<p className="text-xs text-muted-foreground">
Define form fields that users fill in when generating a script. Each parameter maps to a <code className="font-label text-amber-400">{'{{key}}'}</code> placeholder in the script body.
</p>
<ParameterSchemaBuilder
schema={form.parameters_schema}
onChange={v => updateField('parameters_schema', v)}
/>
</section>
{/* ── Fixed Action Bar ──────────────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-20 border-t border-border bg-background/80 backdrop-blur-sm px-6 py-3">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-5 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{templateId ? 'Save Changes' : 'Create Template'}
</button>
<button
type="button"
onClick={handleBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-4 py-2"
>
Cancel
</button>
</div>
{templateId && (
deleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-500">Delete this template?</span>
<button
type="button"
onClick={handleDelete}
className="text-xs font-label text-rose-500 hover:text-rose-400 px-2 py-1"
>
Confirm
</button>
<button
type="button"
onClick={() => setDeleteConfirm(false)}
className="text-xs font-label text-muted-foreground hover:text-foreground px-2 py-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirm(true)}
className="flex items-center gap-1.5 text-sm text-rose-500 hover:text-rose-400 transition-colors px-3 py-2"
>
<Trash2 size={14} />
Delete
</button>
)
)}
</div>
</div>
{/* Save error */}
{saveError && (
<p className="text-sm text-rose-500 text-center">{saveError}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode, ArrowLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions'
import { scriptsApi } from '@/api'
import type { ScriptTemplateListItem, ScriptCategoryResponse } from '@/types'
const COMPLEXITY_CLASSES = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
} as const
interface Props {
onEdit: (id: string) => void
onCreate: () => void
}
export function ScriptTemplateListView({ onEdit, onCreate }: Props) {
const [templates, setTemplates] = useState<ScriptTemplateListItem[]>([])
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const { canManageScriptTemplate, canCreateScriptTemplate } = usePermissions()
const loadData = async () => {
setIsLoading(true)
try {
const [tpls, cats] = await Promise.all([
scriptsApi.getManagedTemplates(searchQuery ? { search: searchQuery } : undefined),
scriptsApi.getCategories(),
])
setTemplates(tpls)
setCategories(cats)
} catch {
// silently fail
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadData()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const timer = setTimeout(() => {
loadData()
}, 300)
return () => clearTimeout(timer)
}, [searchQuery]) // eslint-disable-line react-hooks/exhaustive-deps
const handleDelete = async (id: string) => {
try {
await scriptsApi.deleteTemplate(id)
setTemplates(prev => prev.filter(t => t.id !== id))
setDeleteConfirm(null)
} catch {
// silently fail
}
}
const getCategoryName = (categoryId: string) =>
categories.find(c => c.id === categoryId)?.name ?? 'Unknown'
return (
<div className="flex flex-col gap-4">
{/* Back link */}
<Link
to="/scripts"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<ArrowLeft size={12} />
Back to Script Library
</Link>
{/* Header row */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Manage Templates</h1>
<p className="text-sm text-muted-foreground mt-1">
Create and edit PowerShell script templates.
</p>
</div>
{canCreateScriptTemplate && (
<button
type="button"
onClick={onCreate}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20"
>
<Plus size={16} />
New Template
</button>
)}
</div>
{/* Search */}
<div className="relative w-64">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search templates…"
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)]"
/>
</div>
{/* Template list */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="glass-card-static flex flex-col items-center justify-center gap-3 py-12 text-center">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
{searchQuery ? 'No templates match your search' : 'No templates yet. Create your first one!'}
</p>
</div>
) : (
<div className="glass-card-static overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Name</th>
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Category</th>
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Complexity</th>
<th className="text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Scope</th>
<th className="text-right font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Uses</th>
<th className="text-right font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{templates.map(t => (
<tr
key={t.id}
className="border-b border-border last:border-b-0 hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-3">
<span className="text-foreground font-medium">{t.name}</span>
{t.description && (
<p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{t.description}</p>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{getCategoryName(t.category_id)}</td>
<td className="px-4 py-3">
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[t.complexity])}>
{t.complexity}
</span>
</td>
<td className="px-4 py-3">
<span className={cn(
'inline-flex items-center gap-1 font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
t.team_id
? 'text-primary bg-primary/10 border-primary/20'
: 'text-muted-foreground bg-white/5 border-border'
)}>
{t.team_id ? <><Users size={10} /> Team</> : <><UserIcon size={10} /> Personal</>}
</span>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">{t.usage_count}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{canManageScriptTemplate(t) && (
<>
<button
type="button"
onClick={() => onEdit(t.id)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
title="Edit template"
>
<Pencil size={14} />
</button>
{deleteConfirm === t.id ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleDelete(t.id)}
className="text-[0.625rem] font-label text-rose-500 hover:text-rose-400 px-1.5 py-0.5"
>
Confirm
</button>
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="text-[0.625rem] font-label text-muted-foreground hover:text-foreground px-1.5 py-0.5"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirm(t.id)}
className="p-1.5 rounded-md text-muted-foreground hover:text-rose-500 hover:bg-white/5 transition-colors"
title="Delete template"
>
<Trash2 size={14} />
</button>
)}
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}