feat: add ScriptTemplateEditor — full CRUD form with metadata, script body, and parameters
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
423
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal file
423
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { ArrowLeft, Loader2, Save, 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 type {
|
||||||
|
ScriptTemplateDetail,
|
||||||
|
ScriptCategoryResponse,
|
||||||
|
ScriptParametersSchema,
|
||||||
|
ScriptTemplateCreateRequest,
|
||||||
|
ScriptTemplateUpdateRequest,
|
||||||
|
} 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 { canShareScriptTemplate } = usePermissions()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user