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:
98
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal file
98
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Single-pass PowerShell syntax highlighter.
|
||||
*
|
||||
* Uses a combined alternation regex so tokens matched earlier in the list
|
||||
* cannot be re-coloured by later rules (e.g. a variable inside a string
|
||||
* is captured by the string rule and won't be re-matched by the variable rule).
|
||||
*
|
||||
* Priority order:
|
||||
* 1. Comments /#[^\r\n]star/
|
||||
* 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|...)\b/
|
||||
*
|
||||
* Note: variables (priority 4) consume $foreach before keywords (priority 7)
|
||||
* can match — this is intentional PowerShell behaviour.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
const TOKEN_REGEX = new RegExp(
|
||||
[
|
||||
/#[^\r\n]*/, // 1. comments
|
||||
/"[^"]*"|'[^']*'/, // 2. string literals
|
||||
/\{\{[^}]+\}\}/, // 3. unfilled placeholders
|
||||
/\$\w+/, // 4. variables
|
||||
/[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun)
|
||||
/-[A-Za-z]+/, // 6. parameters
|
||||
/\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords
|
||||
]
|
||||
.map(r => r.source)
|
||||
.join('|'),
|
||||
'g'
|
||||
)
|
||||
|
||||
const TOKEN_CLASSES: Record<string, string> = {
|
||||
comment: 'text-[#8b949e]',
|
||||
string: 'text-[#a5d6ff]',
|
||||
placeholder: 'text-amber-400 underline decoration-dashed',
|
||||
variable: 'text-[#79c0ff]',
|
||||
cmdlet: 'text-[#22d3ee]',
|
||||
parameter: 'text-[#d2a8ff]',
|
||||
keyword: 'text-[#ff7b72]',
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
'if', 'else', 'elseif', 'foreach', 'for', 'while',
|
||||
'function', 'return', 'try', 'catch', 'finally', 'param', 'switch',
|
||||
])
|
||||
|
||||
function classify(token: string): string {
|
||||
if (token.startsWith('#')) return 'comment'
|
||||
if (token.startsWith('"') || token.startsWith("'")) return 'string'
|
||||
if (token.startsWith('{{')) return 'placeholder'
|
||||
if (token.startsWith('$')) return 'variable'
|
||||
if (/^-[A-Za-z]+$/.test(token)) return 'parameter'
|
||||
if (KEYWORDS.has(token)) return 'keyword'
|
||||
return 'cmdlet'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
script: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PowerShellHighlighter({ script, className }: Props) {
|
||||
const parts: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
TOKEN_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = TOKEN_REGEX.exec(script)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(script.slice(lastIndex, match.index))
|
||||
}
|
||||
const token = match[0]
|
||||
const kind = classify(token)
|
||||
parts.push(
|
||||
<span key={match.index} className={TOKEN_CLASSES[kind]}>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
lastIndex = match.index + token.length
|
||||
}
|
||||
|
||||
if (lastIndex < script.length) {
|
||||
parts.push(script.slice(lastIndex))
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className={className ?? "font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"}>
|
||||
<code>{parts}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
214
frontend/src/components/scripts/ScriptConfigurePane.tsx
Normal file
214
frontend/src/components/scripts/ScriptConfigurePane.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react'
|
||||
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check, ShieldAlert } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { ScriptParameterForm } from './ScriptParameterForm'
|
||||
|
||||
const COMPLEXITY_CLASSES = {
|
||||
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
|
||||
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
|
||||
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
canGenerate: boolean
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
|
||||
const categories = useScriptGeneratorStore(s => s.categories)
|
||||
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
|
||||
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
|
||||
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
|
||||
const generateError = useScriptGeneratorStore(s => s.generateError)
|
||||
const generate = useScriptGeneratorStore(s => s.generate)
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!generatedScript) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedScript)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!generatedScript || !selectedTemplate) return
|
||||
const blob = new Blob([generatedScript], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedTemplate.slug}.ps1`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoadingDetail) {
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 size={28} className="text-primary animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// First-selection failure state
|
||||
if (!selectedTemplate) {
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<Terminal size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Failed to load template.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
|
||||
const displayTags = selectedTemplate.tags.slice(0, 3)
|
||||
const extraTagCount = selectedTemplate.tags.length - 3
|
||||
|
||||
return (
|
||||
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
|
||||
>
|
||||
<ArrowLeft size={12} />
|
||||
Back to library
|
||||
</button>
|
||||
|
||||
{/* Template header */}
|
||||
<div className="mb-3">
|
||||
<h2 className="text-base font-semibold font-heading text-foreground">
|
||||
{selectedTemplate.name}
|
||||
</h2>
|
||||
{selectedTemplate.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
||||
{selectedTemplate.requires_elevation && (
|
||||
<span
|
||||
title="Requires administrator elevation"
|
||||
className="inline-flex items-center gap-1 font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
|
||||
>
|
||||
<ShieldAlert size={11} />
|
||||
Elevated
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
|
||||
COMPLEXITY_CLASSES[selectedTemplate.complexity]
|
||||
)}>
|
||||
{selectedTemplate.complexity}
|
||||
</span>
|
||||
{categoryName && (
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
{displayTags.map(tag => (
|
||||
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTagCount > 0 && (
|
||||
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border mt-3 pt-3" />
|
||||
|
||||
{/* Parameter form */}
|
||||
<ScriptParameterForm canGenerate={canGenerate} />
|
||||
|
||||
{/* Warnings */}
|
||||
{generationWarnings.length > 0 && (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3 mt-4">
|
||||
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
|
||||
<AlertTriangle size={13} />
|
||||
Warnings
|
||||
</div>
|
||||
{generationWarnings.map((w) => (
|
||||
<p key={w} className="text-xs text-amber-400/80">{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-col gap-2 mt-4 pt-1">
|
||||
<span title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => generate()}
|
||||
disabled={isGenerating || !canGenerate}
|
||||
className="w-full flex items-center justify-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 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
{isGenerating && <Loader2 size={14} className="animate-spin" />}
|
||||
Generate Script
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={!generatedScript || !canGenerate}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download size={14} />
|
||||
Download .ps1
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!generatedScript || !canGenerate}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate error */}
|
||||
{generateError && (
|
||||
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/scripts/ScriptFilterBar.tsx
Normal file
82
frontend/src/components/scripts/ScriptFilterBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
|
||||
interface Props {
|
||||
inputValue: string
|
||||
setInputValue: (value: string) => void
|
||||
}
|
||||
|
||||
export function ScriptFilterBar({ inputValue, setInputValue }: Props) {
|
||||
const categories = useScriptGeneratorStore(s => s.categories)
|
||||
const activeCategoryId = useScriptGeneratorStore(s => s.activeCategoryId)
|
||||
const setCategory = useScriptGeneratorStore(s => s.setCategory)
|
||||
const setSearch = useScriptGeneratorStore(s => s.setSearch)
|
||||
|
||||
// Debounce: 300ms after the input value settles, push to store.
|
||||
// Skip on initial mount (store.searchQuery is already '' and page already called loadTemplates).
|
||||
const isFirstRender = useRef(true)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearch(inputValue)
|
||||
}, 300)
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [inputValue, setSearch])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Category pills */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategory(null)}
|
||||
className={cn(
|
||||
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||||
activeCategoryId === null
|
||||
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
|
||||
: 'border-border text-muted-foreground hover:border-[rgba(255,255,255,0.12)] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.id)}
|
||||
className={cn(
|
||||
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||||
activeCategoryId === cat.id
|
||||
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
|
||||
: 'border-border text-muted-foreground hover:border-[rgba(255,255,255,0.12)] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative ml-auto">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none z-10" />
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
placeholder="Search templates..."
|
||||
className="pl-8 w-52"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
frontend/src/components/scripts/ScriptParameterField.tsx
Normal file
156
frontend/src/components/scripts/ScriptParameterField.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import type { ScriptParameter } from '@/types'
|
||||
|
||||
interface Props {
|
||||
param: ScriptParameter
|
||||
value: string
|
||||
error: string | undefined
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function ScriptParameterField({ param, value, error, disabled }: Props) {
|
||||
const setParamValue = useScriptGeneratorStore(s => s.setParamValue)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const id = `param-${param.key}`
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setParamValue(param.key, e.target.value)
|
||||
}
|
||||
|
||||
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setParamValue(param.key, e.target.checked ? 'true' : 'false')
|
||||
}
|
||||
|
||||
let input: React.ReactNode
|
||||
|
||||
// Track whether the shared Input/Textarea component renders the error internally
|
||||
// (so we skip the manual <p> at the bottom for these types)
|
||||
let errorRenderedByComponent = false
|
||||
|
||||
if (param.type === 'text' || param.type === 'multi_text' || param.type === 'number') {
|
||||
errorRenderedByComponent = true
|
||||
input = (
|
||||
<Input
|
||||
id={id}
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={
|
||||
param.type === 'multi_text'
|
||||
? 'Comma-separated values'
|
||||
: (param.placeholder ?? undefined)
|
||||
}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'password') {
|
||||
errorRenderedByComponent = true
|
||||
input = (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={param.placeholder ?? undefined}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
} else if (param.type === 'textarea') {
|
||||
errorRenderedByComponent = true
|
||||
input = (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={param.placeholder ?? undefined}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
} else if (param.type === 'select') {
|
||||
input = (
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{(param.options ?? []).map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
} else if (param.type === 'boolean') {
|
||||
input = (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={value === 'true'}
|
||||
onChange={handleCheckbox}
|
||||
disabled={disabled}
|
||||
className="rounded border-border disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label htmlFor={id} className="text-sm text-foreground">
|
||||
{param.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// Fallback for unknown types
|
||||
errorRenderedByComponent = true
|
||||
input = (
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Boolean renders its own label inline; all others show the label above
|
||||
const showTopLabel = param.type !== 'boolean'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{showTopLabel && (
|
||||
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{input}
|
||||
{param.help_text && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{param.help_text}</p>
|
||||
)}
|
||||
{!errorRenderedByComponent && error && (
|
||||
<p className="mt-1.5 text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/scripts/ScriptParameterForm.tsx
Normal file
70
frontend/src/components/scripts/ScriptParameterForm.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { ScriptParameterField } from './ScriptParameterField'
|
||||
import type { ScriptParametersSchema, ScriptParameter } from '@/types'
|
||||
|
||||
interface Props {
|
||||
canGenerate: boolean
|
||||
}
|
||||
|
||||
export function ScriptParameterForm({ canGenerate }: Props) {
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
const paramValues = useScriptGeneratorStore(s => s.paramValues)
|
||||
const formErrors = useScriptGeneratorStore(s => s.formErrors)
|
||||
|
||||
if (!selectedTemplate) return null
|
||||
|
||||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||||
const parameters = (schema?.parameters ?? []).slice().sort((a, b) => a.order - b.order)
|
||||
|
||||
// Group parameters: null-group first, then named groups in order of first appearance
|
||||
const ungrouped = parameters.filter(p => p.group === null)
|
||||
const groupOrder: string[] = []
|
||||
const grouped: Record<string, ScriptParameter[]> = {}
|
||||
for (const p of parameters) {
|
||||
if (p.group !== null) {
|
||||
if (!grouped[p.group]) {
|
||||
grouped[p.group] = []
|
||||
groupOrder.push(p.group)
|
||||
}
|
||||
grouped[p.group].push(p)
|
||||
}
|
||||
}
|
||||
|
||||
const renderParam = (param: ScriptParameter) => (
|
||||
<ScriptParameterField
|
||||
key={param.key}
|
||||
param={param}
|
||||
value={paramValues[param.key] ?? ''}
|
||||
error={formErrors[param.key] || undefined}
|
||||
disabled={!canGenerate}
|
||||
/>
|
||||
)
|
||||
|
||||
if (parameters.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-white/[0.02] px-3 py-3">
|
||||
<Terminal size={14} className="text-muted-foreground shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This template has no parameters — click <span className="text-foreground font-medium">Generate</span> to produce the script.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{ungrouped.map(renderParam)}
|
||||
{groupOrder.map(group => (
|
||||
<div key={group}>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
|
||||
{group}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{grouped[group].map(renderParam)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/scripts/ScriptPreview.tsx
Normal file
56
frontend/src/components/scripts/ScriptPreview.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { PowerShellHighlighter } from './PowerShellHighlighter'
|
||||
import type { ScriptParametersSchema } from '@/types'
|
||||
|
||||
export function ScriptPreview() {
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
const paramValues = useScriptGeneratorStore(s => s.paramValues)
|
||||
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!selectedTemplate) return null
|
||||
|
||||
// Compute the displayed script
|
||||
let displayScript: string
|
||||
if (generatedScript !== null) {
|
||||
displayScript = generatedScript
|
||||
} else {
|
||||
// Draft mode: client-side {{key}} substitution
|
||||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||||
const parameters = schema?.parameters ?? []
|
||||
displayScript = selectedTemplate.script_body
|
||||
for (const param of parameters) {
|
||||
const placeholder = `{{${param.key}}}`
|
||||
const replacement = param.sensitive
|
||||
? '****'
|
||||
: (paramValues[param.key] ?? '')
|
||||
displayScript = displayScript.replaceAll(placeholder, replacement || placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayScript)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// silently fail — no error displayed
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
|
||||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<PowerShellHighlighter script={displayScript} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
frontend/src/components/scripts/ScriptTemplateList.tsx
Normal file
69
frontend/src/components/scripts/ScriptTemplateList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { FileCode, Search } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { TemplateCard } from './TemplateCard'
|
||||
|
||||
interface Props {
|
||||
inputValue: string
|
||||
onClearSearch: () => void
|
||||
onConfigure: (id: string) => void
|
||||
}
|
||||
|
||||
function TemplateSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="h-4 w-2/3 bg-white/10 rounded" />
|
||||
<div className="h-4 w-14 bg-white/10 rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-full bg-white/5 rounded mb-1" />
|
||||
<div className="h-3 w-3/4 bg-white/5 rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: Props) {
|
||||
const templates = useScriptGeneratorStore(s => s.templates)
|
||||
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
|
||||
|
||||
if (isLoadingTemplates) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<TemplateSkeleton />
|
||||
<TemplateSkeleton />
|
||||
<TemplateSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
if (inputValue !== '') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
|
||||
<Search size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No templates match your search</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSearch}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
|
||||
<FileCode size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No templates found</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{templates.map(template => (
|
||||
<TemplateCard key={template.id} template={template} onConfigure={onConfigure} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/scripts/TemplateCard.tsx
Normal file
72
frontend/src/components/scripts/TemplateCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ScriptTemplateListItem } from '@/types'
|
||||
|
||||
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
|
||||
beginner: 'text-emerald-400 bg-emerald-400/10',
|
||||
intermediate: 'text-amber-400 bg-amber-400/10',
|
||||
advanced: 'text-rose-500 bg-rose-500/10',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
template: ScriptTemplateListItem
|
||||
onConfigure: (id: string) => void
|
||||
}
|
||||
|
||||
export function TemplateCard({ template, onConfigure }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border transition-all',
|
||||
'border-border bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{template.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{template.requires_elevation && (
|
||||
<span title="Requires administrator elevation">
|
||||
<ShieldAlert size={13} className="text-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
|
||||
{template.complexity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
|
||||
<span>{template.usage_count}× used</span>
|
||||
{template.tags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{template.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{template.tags.length > 3 && (
|
||||
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigure(template.id)}
|
||||
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Configure →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user