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:
@@ -21,3 +21,4 @@ export { copilotApi } from './copilot'
|
||||
export { assistantChatApi } from './assistantChat'
|
||||
export { flowTransferApi } from './flowTransfer'
|
||||
export { kbAcceleratorApi } from './kbAccelerator'
|
||||
export { scriptsApi } from './scripts'
|
||||
|
||||
78
frontend/src/api/scripts.ts
Normal file
78
frontend/src/api/scripts.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
ScriptCategoryResponse,
|
||||
ScriptTemplateListItem,
|
||||
ScriptTemplateDetail,
|
||||
ScriptGenerateRequest,
|
||||
ScriptGenerateResponse,
|
||||
ScriptGenerationRecord,
|
||||
ScriptTemplateCreateRequest,
|
||||
ScriptTemplateUpdateRequest,
|
||||
} from '@/types'
|
||||
|
||||
export const scriptsApi = {
|
||||
async getCategories(): Promise<ScriptCategoryResponse[]> {
|
||||
const response = await apiClient.get<ScriptCategoryResponse[]>('/scripts/categories')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTemplates(params?: {
|
||||
category_slug?: string
|
||||
search?: string
|
||||
tags?: string // Phase 3: comma-separated tag filter
|
||||
}): Promise<ScriptTemplateListItem[]> {
|
||||
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getTemplateDetail(id: string): Promise<ScriptTemplateDetail> {
|
||||
const response = await apiClient.get<ScriptTemplateDetail>(`/scripts/templates/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> {
|
||||
const response = await apiClient.post<ScriptGenerateResponse>('/scripts/generate', req)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Phase 3: fetch generation history for the current user
|
||||
async getGenerations(): Promise<ScriptGenerationRecord[]> {
|
||||
const response = await apiClient.get<ScriptGenerationRecord[]>('/scripts/generations')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getManagedTemplates(params?: {
|
||||
category_slug?: string
|
||||
search?: string
|
||||
}): Promise<ScriptTemplateListItem[]> {
|
||||
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', {
|
||||
params: { ...params, managed: true },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createTemplate(data: ScriptTemplateCreateRequest): Promise<ScriptTemplateDetail> {
|
||||
const response = await apiClient.post<ScriptTemplateDetail>('/scripts/templates', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateTemplate(id: string, data: ScriptTemplateUpdateRequest): Promise<ScriptTemplateDetail> {
|
||||
const response = await apiClient.put<ScriptTemplateDetail>(`/scripts/templates/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteTemplate(id: string): Promise<void> {
|
||||
await apiClient.delete(`/scripts/templates/${id}`)
|
||||
},
|
||||
|
||||
async shareTemplate(id: string, shared: boolean): Promise<ScriptTemplateDetail> {
|
||||
const response = await apiClient.patch<ScriptTemplateDetail>(
|
||||
`/scripts/templates/${id}/share`,
|
||||
null,
|
||||
{ params: { shared } },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default scriptsApi
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Sentry from '@sentry/react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, useEffect, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
|
||||
interface FallbackProps {
|
||||
@@ -18,17 +18,20 @@ function isChunkLoadError(error: Error): boolean {
|
||||
}
|
||||
|
||||
function DefaultFallback({ error, resetError }: FallbackProps) {
|
||||
const reloadingRef = useRef(false)
|
||||
|
||||
// Auto-reload on stale chunk errors (happens after deployments)
|
||||
if (isChunkLoadError(error)) {
|
||||
useEffect(() => {
|
||||
if (!isChunkLoadError(error)) return
|
||||
const key = 'rf_boundary_chunk_reload'
|
||||
const lastReload = sessionStorage.getItem(key)
|
||||
const now = Date.now()
|
||||
if (!lastReload || now - Number(lastReload) > 10_000) {
|
||||
sessionStorage.setItem(key, String(now))
|
||||
reloadingRef.current = true
|
||||
window.location.reload()
|
||||
return null
|
||||
}
|
||||
}
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield, Terminal } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -57,6 +57,7 @@ export function AppLayout() {
|
||||
{ path: '/sessions', label: 'Sessions', icon: Clock },
|
||||
{ path: '/shares', label: 'Exports', icon: FileText },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
|
||||
{ path: '/scripts', label: 'Script Library', icon: Terminal },
|
||||
{ path: '/account', label: 'Account', icon: Settings },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles } from 'lucide-react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles, Terminal } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
@@ -83,6 +83,7 @@ export function Sidebar() {
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
<NavItem href="/scripts" icon={Terminal} label="Script Library" collapsed />
|
||||
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
|
||||
@@ -116,6 +117,7 @@ export function Sidebar() {
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||
<NavItem href="/scripts" icon={Terminal} label="Script Library" />
|
||||
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
</div>
|
||||
|
||||
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal file
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal 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 {{key}})</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>
|
||||
)
|
||||
}
|
||||
@@ -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 & Finish</>
|
||||
) : (
|
||||
<><ChevronRight size={13} /> Accept & Next</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal file
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/script-editor/ScriptBodyEditor.tsx
Normal file
53
frontend/src/components/script-editor/ScriptBodyEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
550
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal file
550
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal file
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -71,5 +71,16 @@ export function usePermissions() {
|
||||
canManageCategories: hasMinimumRole(user, 'owner'),
|
||||
canManageGlobalCategories: effectiveRole === 'super_admin',
|
||||
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner',
|
||||
|
||||
canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => {
|
||||
if (!user) return false
|
||||
if (user.is_super_admin) return true
|
||||
if (user.account_role === 'owner') return true
|
||||
return template.created_by === user.id
|
||||
},
|
||||
|
||||
canShareScriptTemplate: effectiveRole === 'super_admin' || effectiveRole === 'owner',
|
||||
|
||||
canCreateScriptTemplate: hasMinimumRole(user, 'engineer'),
|
||||
}
|
||||
}
|
||||
|
||||
300
frontend/src/lib/scriptParameterDetector.ts
Normal file
300
frontend/src/lib/scriptParameterDetector.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { ScriptParameter, ParameterCandidate } from '@/types'
|
||||
|
||||
/**
|
||||
* PowerShell variable names to skip — these are PS internals, not user inputs.
|
||||
*/
|
||||
const SKIP_VARIABLES = new Set([
|
||||
'$ErrorActionPreference',
|
||||
'$WarningPreference',
|
||||
'$VerbosePreference',
|
||||
'$DebugPreference',
|
||||
'$InformationPreference',
|
||||
'$ConfirmPreference',
|
||||
'$ProgressPreference',
|
||||
'$PSDefaultParameterValues',
|
||||
'$PSModuleAutoLoadingPreference',
|
||||
'$OFS',
|
||||
'$FormatEnumerationLimit',
|
||||
'$MaximumHistoryCount',
|
||||
'$_',
|
||||
'$PSItem',
|
||||
'$args',
|
||||
'$input',
|
||||
'$this',
|
||||
'$null',
|
||||
'$true',
|
||||
'$false',
|
||||
])
|
||||
|
||||
/**
|
||||
* Sensitive variable name patterns — if the variable name contains any of these,
|
||||
* suggest password type and mark sensitive.
|
||||
*/
|
||||
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
|
||||
|
||||
/**
|
||||
* Convert a PowerShell variable name to a snake_case key.
|
||||
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
|
||||
*/
|
||||
function toSnakeCase(varName: string): string {
|
||||
const name = varName.replace(/^\$/, '')
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a snake_case key to a human-readable label.
|
||||
* "ou_path" → "OU Path", "server_name" → "Server Name"
|
||||
*/
|
||||
function toLabel(key: string): string {
|
||||
return key
|
||||
.split('_')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
|
||||
*/
|
||||
function inferType(
|
||||
typeAnnotation: string | null,
|
||||
value: string | null,
|
||||
varName: string
|
||||
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
|
||||
if (typeAnnotation) {
|
||||
const t = typeAnnotation.toLowerCase()
|
||||
if (t === 'switch') {
|
||||
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
|
||||
}
|
||||
if (t === 'securestring') {
|
||||
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
|
||||
}
|
||||
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
|
||||
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
|
||||
}
|
||||
if (t === 'bool' || t === 'boolean') {
|
||||
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
|
||||
}
|
||||
}
|
||||
|
||||
if (SENSITIVE_PATTERNS.test(varName)) {
|
||||
return { type: 'password', sensitive: true, reason: 'Variable name suggests sensitive data — marked as sensitive' }
|
||||
}
|
||||
|
||||
if (value !== null) {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '$true' || trimmed === '$false') {
|
||||
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
|
||||
}
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
||||
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
|
||||
}
|
||||
}
|
||||
|
||||
const reason = typeAnnotation
|
||||
? `Detected [${typeAnnotation}] type declaration`
|
||||
: 'Defaulting to text (no type annotation detected)'
|
||||
return { type: 'text', sensitive: false, reason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the default value into the correct JS type.
|
||||
*/
|
||||
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
|
||||
if (value === null) return null
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (type === 'boolean') {
|
||||
if (trimmed === '$true') return true
|
||||
if (trimmed === '$false') return false
|
||||
return null
|
||||
}
|
||||
if (type === 'number') {
|
||||
const n = Number(trimmed)
|
||||
return isNaN(n) ? null : n
|
||||
}
|
||||
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the script-level param() block (not inside any function).
|
||||
*/
|
||||
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
|
||||
const lines = script.split('\n')
|
||||
let functionBraceDepth = 0
|
||||
let paramStart = -1
|
||||
let parenDepth = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim()
|
||||
|
||||
// Track function blocks with brace depth to handle nested braces
|
||||
if (/^function\s+/i.test(trimmed)) {
|
||||
// Count braces on this line and subsequent lines
|
||||
for (const ch of lines[i]) {
|
||||
if (ch === '{') functionBraceDepth++
|
||||
if (ch === '}') functionBraceDepth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Track braces while inside a function
|
||||
if (functionBraceDepth > 0) {
|
||||
for (const ch of lines[i]) {
|
||||
if (ch === '{') functionBraceDepth++
|
||||
if (ch === '}') functionBraceDepth--
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (functionBraceDepth === 0 && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
|
||||
paramStart = i
|
||||
let foundOpen = false
|
||||
for (let j = i; j < lines.length; j++) {
|
||||
for (const ch of lines[j]) {
|
||||
if (ch === '(') { parenDepth++; foundOpen = true }
|
||||
if (ch === ')') parenDepth--
|
||||
if (foundOpen && parenDepth === 0) {
|
||||
return { start: paramStart, end: j }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter candidates from a script-level param() block.
|
||||
*/
|
||||
function extractParamBlockCandidates(
|
||||
script: string,
|
||||
block: { start: number; end: number }
|
||||
): ParameterCandidate[] {
|
||||
const lines = script.split('\n')
|
||||
const candidates: ParameterCandidate[] = []
|
||||
|
||||
// Scan each line in the param block for $VarName patterns.
|
||||
// Lines with [Parameter(...)], [ValidateSet(...)], etc. don't contain $VarName
|
||||
// so they are naturally skipped. The type annotation [string], [int], etc.
|
||||
// appears on the same line as $VarName.
|
||||
const varLineRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,?\s*$)/
|
||||
|
||||
for (let i = block.start; i <= block.end; i++) {
|
||||
const trimmed = lines[i].trim()
|
||||
|
||||
// Skip attribute lines like [Parameter(...)], [ValidateSet(...)], etc.
|
||||
if (/^\[(?:Parameter|ValidateSet|ValidateRange|ValidatePattern|ValidateScript|ValidateLength|ValidateCount|Alias|AllowNull|AllowEmptyString|AllowEmptyCollection)\s*\(/i.test(trimmed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = trimmed.match(varLineRegex)
|
||||
if (!match) continue
|
||||
|
||||
const typeAnnotation = match[1] || null
|
||||
const varName = match[2]
|
||||
const rawDefault = match[3]?.trim() ?? null
|
||||
|
||||
// Skip if type looks like an attribute we didn't catch above
|
||||
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
|
||||
|
||||
const key = toSnakeCase(varName)
|
||||
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
|
||||
const defaultValue = parseDefault(rawDefault, type)
|
||||
|
||||
candidates.push({
|
||||
variableName: `$${varName}`,
|
||||
suggestedKey: key,
|
||||
suggestedLabel: toLabel(key),
|
||||
suggestedType: type,
|
||||
sensitive,
|
||||
defaultValue,
|
||||
source: 'param_block',
|
||||
lineNumber: i + 1,
|
||||
matchedLine: trimmed,
|
||||
inferenceReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter candidates from variable assignments ($Var = 'value').
|
||||
*/
|
||||
function extractAssignmentCandidates(
|
||||
script: string,
|
||||
existingVarNames: Set<string>
|
||||
): ParameterCandidate[] {
|
||||
const lines = script.split('\n')
|
||||
const candidates: ParameterCandidate[] = []
|
||||
const seenVars = new Set<string>()
|
||||
|
||||
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(assignRegex)
|
||||
if (!match) continue
|
||||
|
||||
const fullVar = match[1]
|
||||
const rawValue = match[2].trim()
|
||||
|
||||
if (SKIP_VARIABLES.has(fullVar)) continue
|
||||
if (existingVarNames.has(fullVar)) continue
|
||||
if (seenVars.has(fullVar)) continue
|
||||
|
||||
if (!/^['"].*['"]$/.test(rawValue) &&
|
||||
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
|
||||
!/^\$(true|false)$/i.test(rawValue)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/\{\{.*?\}\}/.test(rawValue)) continue
|
||||
|
||||
seenVars.add(fullVar)
|
||||
|
||||
const varName = fullVar.replace(/^\$/, '')
|
||||
const key = toSnakeCase(varName)
|
||||
const { type, sensitive, reason } = inferType(null, rawValue, varName)
|
||||
const defaultValue = parseDefault(rawValue, type)
|
||||
|
||||
candidates.push({
|
||||
variableName: fullVar,
|
||||
suggestedKey: key,
|
||||
suggestedLabel: toLabel(key),
|
||||
suggestedType: type,
|
||||
sensitive,
|
||||
defaultValue,
|
||||
source: 'assignment',
|
||||
lineNumber: i + 1,
|
||||
matchedLine: lines[i].trim(),
|
||||
inferenceReason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect parameter candidates in a PowerShell script body.
|
||||
*/
|
||||
export function detectParameterCandidates(script: string): ParameterCandidate[] {
|
||||
if (!script.trim()) return []
|
||||
|
||||
const paramBlock = findScriptLevelParamBlock(script)
|
||||
const paramCandidates = paramBlock
|
||||
? extractParamBlockCandidates(script, paramBlock)
|
||||
: []
|
||||
|
||||
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
|
||||
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
|
||||
|
||||
return [...paramCandidates, ...assignmentCandidates]
|
||||
}
|
||||
98
frontend/src/pages/ScriptLibraryPage.tsx
Normal file
98
frontend/src/pages/ScriptLibraryPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Terminal, Settings } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
|
||||
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
|
||||
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
|
||||
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
|
||||
|
||||
export default function ScriptLibraryPage() {
|
||||
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
|
||||
// inputValue owned here so it survives Configure ↔ Browse transitions
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
|
||||
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
|
||||
const setSearch = useScriptGeneratorStore(s => s.setSearch)
|
||||
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
|
||||
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
|
||||
const { isEngineer } = usePermissions()
|
||||
const canGenerate = isEngineer
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories().then(() => loadTemplates())
|
||||
}, [loadCategories, loadTemplates])
|
||||
|
||||
const onClearSearch = () => {
|
||||
setInputValue('')
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
const onConfigure = (id: string) => {
|
||||
selectTemplate(id)
|
||||
setPaneMode('configure')
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
clearOutput()
|
||||
setPaneMode('browse')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6 h-full">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
|
||||
</p>
|
||||
{isEngineer && (
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
|
||||
{/* Left pane — Browse or Configure mode */}
|
||||
{paneMode === 'browse' ? (
|
||||
<div className="glass-card-static flex flex-col overflow-hidden">
|
||||
<div className="p-2 pb-0">
|
||||
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScriptTemplateList
|
||||
inputValue={inputValue}
|
||||
onClearSearch={onClearSearch}
|
||||
onConfigure={onConfigure}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
|
||||
)}
|
||||
|
||||
{/* Right pane — read-only ScriptPreview */}
|
||||
{selectedTemplate === null ? (
|
||||
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
|
||||
<Terminal size={40} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">Select a template to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card-static h-full overflow-y-auto p-4">
|
||||
<ScriptPreview />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/pages/ScriptManagePage.tsx
Normal file
44
frontend/src/pages/ScriptManagePage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react'
|
||||
import { ScriptTemplateListView } from '@/components/script-editor/ScriptTemplateListView'
|
||||
import { ScriptTemplateEditor } from '@/components/script-editor/ScriptTemplateEditor'
|
||||
|
||||
export default function ScriptManagePage() {
|
||||
const [mode, setMode] = useState<'list' | 'edit'>('list')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setEditingId(id)
|
||||
setMode('edit')
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null)
|
||||
setMode('edit')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setEditingId(null)
|
||||
setMode('list')
|
||||
}
|
||||
|
||||
const handleSaved = () => {
|
||||
setEditingId(null)
|
||||
setMode('list')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{mode === 'list' ? (
|
||||
<ScriptTemplateListView onEdit={handleEdit} onCreate={handleCreate} />
|
||||
) : (
|
||||
<ScriptTemplateEditor
|
||||
templateId={editingId}
|
||||
onBack={handleBack}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||
const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
|
||||
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
|
||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
@@ -160,6 +162,8 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
|
||||
{ path: 'feedback', element: page(FeedbackPage) },
|
||||
{ path: 'step-library', element: page(StepLibraryPage) },
|
||||
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||||
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'guides', element: page(GuidesHubPage) },
|
||||
|
||||
192
frontend/src/store/scriptGeneratorStore.ts
Normal file
192
frontend/src/store/scriptGeneratorStore.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { create } from 'zustand'
|
||||
import { scriptsApi } from '@/api'
|
||||
import type {
|
||||
ScriptCategoryResponse,
|
||||
ScriptTemplateListItem,
|
||||
ScriptTemplateDetail,
|
||||
ScriptParametersSchema,
|
||||
} from '@/types'
|
||||
|
||||
interface ScriptGeneratorState {
|
||||
// Template browsing
|
||||
categories: ScriptCategoryResponse[]
|
||||
templates: ScriptTemplateListItem[]
|
||||
selectedTemplate: ScriptTemplateDetail | null
|
||||
searchQuery: string
|
||||
activeCategoryId: string | null // null = "All"
|
||||
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
|
||||
isLoadingDetail: boolean // drives spinner in ScriptConfigurePane
|
||||
|
||||
// Form
|
||||
paramValues: Record<string, string> // keyed by ScriptParameter.key; booleans as 'true'/'false'
|
||||
formErrors: Record<string, string> // keyed by ScriptParameter.key
|
||||
|
||||
// Output
|
||||
generatedScript: string | null
|
||||
generationId: string | null
|
||||
generationWarnings: string[]
|
||||
isGenerating: boolean
|
||||
generateError: string | null
|
||||
|
||||
// Actions
|
||||
loadCategories: () => Promise<void>
|
||||
loadTemplates: () => Promise<void>
|
||||
selectTemplate: (id: string) => Promise<void>
|
||||
setCategory: (id: string | null) => void
|
||||
setSearch: (query: string) => void
|
||||
setParamValue: (key: string, value: string) => void
|
||||
validate: () => boolean
|
||||
generate: (sessionId?: string) => Promise<void>
|
||||
clearOutput: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get) => ({
|
||||
// Initial state
|
||||
categories: [],
|
||||
templates: [],
|
||||
selectedTemplate: null,
|
||||
searchQuery: '',
|
||||
activeCategoryId: null,
|
||||
isLoadingTemplates: false,
|
||||
isLoadingDetail: false,
|
||||
paramValues: {},
|
||||
formErrors: {},
|
||||
generatedScript: null,
|
||||
generationId: null,
|
||||
generationWarnings: [],
|
||||
isGenerating: false,
|
||||
generateError: null,
|
||||
|
||||
loadCategories: async () => {
|
||||
try {
|
||||
const categories = await scriptsApi.getCategories()
|
||||
set({ categories })
|
||||
} catch {
|
||||
// silently ignore — categories remain empty, UI degrades gracefully
|
||||
}
|
||||
},
|
||||
|
||||
loadTemplates: async () => {
|
||||
set({ isLoadingTemplates: true })
|
||||
try {
|
||||
const { activeCategoryId, categories, searchQuery } = get()
|
||||
const category = categories.find(c => c.id === activeCategoryId)
|
||||
const params: { category_slug?: string; search?: string } = {}
|
||||
if (category) params.category_slug = category.slug
|
||||
if (searchQuery) params.search = searchQuery
|
||||
const templates = await scriptsApi.getTemplates(params)
|
||||
set({ templates, isLoadingTemplates: false })
|
||||
} catch {
|
||||
set({ isLoadingTemplates: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectTemplate: async (id: string) => {
|
||||
set({ isLoadingDetail: true })
|
||||
try {
|
||||
const detail = await scriptsApi.getTemplateDetail(id)
|
||||
// Populate paramValues from parameter defaults
|
||||
const schema = detail.parameters_schema as ScriptParametersSchema
|
||||
const parameters = schema?.parameters ?? []
|
||||
const paramValues: Record<string, string> = {}
|
||||
for (const param of parameters) {
|
||||
const d = param.default
|
||||
if (d === null || d === undefined) paramValues[param.key] = ''
|
||||
else if (typeof d === 'boolean') paramValues[param.key] = d ? 'true' : 'false'
|
||||
else paramValues[param.key] = String(d)
|
||||
}
|
||||
set({
|
||||
selectedTemplate: detail,
|
||||
paramValues,
|
||||
formErrors: {},
|
||||
generatedScript: null,
|
||||
generationId: null,
|
||||
generationWarnings: [],
|
||||
generateError: null,
|
||||
isLoadingDetail: false,
|
||||
})
|
||||
} catch {
|
||||
set({ isLoadingDetail: false })
|
||||
}
|
||||
},
|
||||
|
||||
setCategory: (id: string | null) => {
|
||||
set({ activeCategoryId: id })
|
||||
get().loadTemplates()
|
||||
},
|
||||
|
||||
setSearch: (query: string) => {
|
||||
set({ searchQuery: query })
|
||||
get().loadTemplates()
|
||||
},
|
||||
|
||||
setParamValue: (key: string, value: string) => {
|
||||
set(state => ({
|
||||
paramValues: { ...state.paramValues, [key]: value },
|
||||
formErrors: { ...state.formErrors, [key]: '' }, // clear error on change
|
||||
}))
|
||||
},
|
||||
|
||||
validate: () => {
|
||||
const { selectedTemplate, paramValues } = get()
|
||||
if (!selectedTemplate) return true
|
||||
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
|
||||
const parameters = schema?.parameters ?? []
|
||||
const errors: Record<string, string> = {}
|
||||
for (const param of parameters) {
|
||||
if (param.required && !paramValues[param.key]) {
|
||||
errors[param.key] = `${param.label} is required`
|
||||
}
|
||||
}
|
||||
set({ formErrors: errors })
|
||||
return Object.keys(errors).length === 0
|
||||
},
|
||||
|
||||
generate: async (sessionId?: string) => {
|
||||
const { selectedTemplate, paramValues } = get()
|
||||
if (!selectedTemplate) return
|
||||
if (!get().validate()) return
|
||||
|
||||
set({ isGenerating: true, generateError: null })
|
||||
try {
|
||||
const response = await scriptsApi.generate({
|
||||
template_id: selectedTemplate.id,
|
||||
parameters: paramValues,
|
||||
...(sessionId ? { session_id: sessionId } : {}),
|
||||
})
|
||||
set({
|
||||
generatedScript: response.script,
|
||||
generationId: response.id,
|
||||
generationWarnings: response.warnings,
|
||||
isGenerating: false,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } }
|
||||
const message = axiosErr.response?.data?.detail ?? 'Failed to generate script'
|
||||
set({ generateError: message, isGenerating: false })
|
||||
}
|
||||
},
|
||||
|
||||
clearOutput: () => {
|
||||
set({
|
||||
generatedScript: null,
|
||||
generationId: null,
|
||||
generationWarnings: [],
|
||||
generateError: null,
|
||||
})
|
||||
},
|
||||
|
||||
// Exposed for Phase 3 callers (session execution context).
|
||||
// Does NOT clear selectedTemplate, categories, templates, or browsing state.
|
||||
reset: () => {
|
||||
set({
|
||||
paramValues: {},
|
||||
formErrors: {},
|
||||
generatedScript: null,
|
||||
generationId: null,
|
||||
generationWarnings: [],
|
||||
generateError: null,
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -87,3 +87,5 @@ export type {
|
||||
KBCommitResponse,
|
||||
KBQuotaResponse,
|
||||
} from './kbAccelerator'
|
||||
|
||||
export * from './scripts'
|
||||
|
||||
137
frontend/src/types/scripts.ts
Normal file
137
frontend/src/types/scripts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export interface ScriptCategoryResponse {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
template_count: number
|
||||
}
|
||||
|
||||
export interface ScriptTemplateListItem {
|
||||
id: string
|
||||
category_id: string
|
||||
team_id: string | null
|
||||
created_by: string | null
|
||||
name: string
|
||||
slug: string
|
||||
description: string | null
|
||||
tags: string[]
|
||||
complexity: 'beginner' | 'intermediate' | 'advanced' // must match backend ScriptComplexity enum exactly
|
||||
estimated_runtime: string | null
|
||||
requires_elevation: boolean
|
||||
requires_modules: string[]
|
||||
is_verified: boolean
|
||||
usage_count: number
|
||||
}
|
||||
|
||||
export interface ScriptParameterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface ScriptParameterValidation {
|
||||
min_value?: number // matches backend field name (not 'min')
|
||||
max_value?: number // matches backend field name (not 'max')
|
||||
pattern?: string
|
||||
min_length?: number
|
||||
max_length?: number
|
||||
}
|
||||
|
||||
export interface ScriptParameter {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea'
|
||||
required: boolean
|
||||
placeholder: string | null
|
||||
group: string | null
|
||||
order: number
|
||||
help_text: string | null
|
||||
options: ScriptParameterOption[] | null // for select type
|
||||
default: string | boolean | number | null
|
||||
validation: ScriptParameterValidation | null
|
||||
sensitive: boolean
|
||||
}
|
||||
|
||||
export interface ScriptParametersSchema {
|
||||
parameters: ScriptParameter[]
|
||||
}
|
||||
|
||||
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
|
||||
use_case: string | null
|
||||
script_body: string
|
||||
// NOTE: backend types this as `dict` — arrives as unknown at runtime.
|
||||
// Always access via cast: (detail.parameters_schema as ScriptParametersSchema).parameters ?? []
|
||||
parameters_schema: ScriptParametersSchema
|
||||
default_values: Record<string, unknown> // template-level metadata; not used in Phase 2
|
||||
validation_rules: Record<string, unknown> // template-level metadata; not used in Phase 2
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ScriptGenerateRequest {
|
||||
template_id: string
|
||||
parameters: Record<string, unknown>
|
||||
session_id?: string // Phase 3: passed when generating inside a session
|
||||
}
|
||||
|
||||
export interface ScriptGenerateResponse {
|
||||
id: string // generation UUID
|
||||
script: string // rendered PowerShell
|
||||
warnings: string[]
|
||||
metadata: {
|
||||
template_name: string
|
||||
template_version: number
|
||||
requires_elevation: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScriptGenerationRecord {
|
||||
id: string
|
||||
template_id: string
|
||||
template_name: string
|
||||
parameters_used: Record<string, unknown> // sensitive values already redacted by backend
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ScriptTemplateCreateRequest {
|
||||
category_id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
use_case?: string | null
|
||||
script_body: string
|
||||
parameters_schema: ScriptParametersSchema
|
||||
tags?: string[]
|
||||
complexity?: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimated_runtime?: string | null
|
||||
requires_elevation?: boolean
|
||||
requires_modules?: string[]
|
||||
}
|
||||
|
||||
export interface ScriptTemplateUpdateRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
use_case?: string | null
|
||||
script_body?: string
|
||||
parameters_schema?: ScriptParametersSchema
|
||||
tags?: string[]
|
||||
complexity?: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimated_runtime?: string | null
|
||||
requires_elevation?: boolean
|
||||
requires_modules?: string[]
|
||||
}
|
||||
|
||||
export interface ParameterCandidate {
|
||||
variableName: string
|
||||
suggestedKey: string
|
||||
suggestedLabel: string
|
||||
suggestedType: ScriptParameter['type']
|
||||
sensitive: boolean
|
||||
defaultValue: string | boolean | number | null
|
||||
source: 'param_block' | 'assignment'
|
||||
lineNumber: number
|
||||
matchedLine: string
|
||||
inferenceReason: string
|
||||
}
|
||||
Reference in New Issue
Block a user