79 KiB
Script Template Editor — Implementation Plan
For Claude: REQUIRED: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Script Template Editor page (/scripts/manage) where engineers create personal templates, owners/admins can edit any and toggle "Share with team", and super admins have full access.
Architecture: Refactor backend permission checks on existing CRUD endpoints to allow engineers to manage their own templates. Add PATCH /share endpoint. Build a new frontend page with list/editor modes, a visual parameter schema builder with JSON toggle, and a PowerShell script body editor.
Tech Stack: Python FastAPI, SQLAlchemy 2.0, Pydantic v2, React 19, TypeScript, Zustand, Axios (apiClient), Tailwind CSS v3, Lucide React.
File Structure
| File | Action | Responsibility |
|---|---|---|
backend/app/api/endpoints/scripts.py |
Modify | Refactor permissions, add /share endpoint, add managed filter |
backend/app/schemas/script_template.py |
Modify | Add created_by to response schemas |
backend/app/core/permissions.py |
Modify | Add can_manage_script_template() |
backend/tests/test_script_templates.py |
Create | Integration tests for permission changes + share endpoint |
frontend/src/types/scripts.ts |
Modify | Add created_by to list/detail types, add create/update request types |
frontend/src/api/scripts.ts |
Modify | Add createTemplate, updateTemplate, deleteTemplate, shareTemplate, getManagedTemplates |
frontend/src/hooks/usePermissions.ts |
Modify | Add canManageScriptTemplate() check |
frontend/src/pages/ScriptManagePage.tsx |
Create | Page shell — list/editor mode toggle |
frontend/src/components/script-editor/ScriptTemplateListView.tsx |
Create | Template list with filters, search, scope badges, actions |
frontend/src/components/script-editor/ScriptTemplateEditor.tsx |
Create | Full editor form — metadata, script body, parameters, actions |
frontend/src/components/script-editor/ScriptBodyEditor.tsx |
Create | Textarea with PowerShell highlighting overlay |
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx |
Create | Visual parameter builder + JSON toggle |
frontend/src/components/script-editor/ParameterCard.tsx |
Create | Single parameter editor (expandable card) |
frontend/src/router.tsx |
Modify | Add /scripts/manage route |
frontend/src/pages/ScriptLibraryPage.tsx |
Modify | Add "Manage Templates" link for engineers+ |
Chunk 1: Backend — Permission Refactor + Share Endpoint
Task 1: Add can_manage_script_template to permissions.py
Files:
-
Modify:
backend/app/core/permissions.py -
Step 1: Add the permission function
Add at the end of backend/app/core/permissions.py:
def can_manage_script_template(user: User, template_created_by: Optional[UUID], template_account_id: Optional[UUID] = None) -> bool:
"""Can the user edit/delete this script template?
- Super admins can manage any template
- Account owners can manage any template in their account
- Engineers can manage templates they created
"""
if user.is_super_admin:
return True
if user.account_role == "owner" and template_account_id == user.account_id and user.account_id is not None:
return True
if template_created_by == user.id:
return True
return False
Add the import at the top if not present:
from uuid import UUID
- Step 2: Verify no import errors
docker exec resolutionflow_backend python -c "from app.core.permissions import can_manage_script_template; print('OK')"
Expected: OK
- Step 3: Commit
git add backend/app/core/permissions.py
git commit -m "feat: add can_manage_script_template permission check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 2: Add created_by to response schemas
Files:
-
Modify:
backend/app/schemas/script_template.py -
Step 1: Add
created_byfield toScriptTemplateListItem
In backend/app/schemas/script_template.py, add created_by to ScriptTemplateListItem (line ~82, after team_id):
class ScriptTemplateListItem(BaseModel):
id: UUID
category_id: UUID
team_id: Optional[UUID] = None
created_by: Optional[UUID] = None # ← ADD THIS LINE
name: str
slug: str
# ... rest stays the same
- Step 2: Verify import works
docker exec resolutionflow_backend python -c "from app.schemas.script_template import ScriptTemplateListItem; print(ScriptTemplateListItem.model_fields.keys())"
Expected: output includes created_by
- Step 3: Commit
git add backend/app/schemas/script_template.py
git commit -m "feat: expose created_by in script template response schemas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 3: Refactor script endpoint permissions
Files:
-
Modify:
backend/app/api/endpoints/scripts.py -
Step 1: Replace
_require_team_adminwith new permission logic
In backend/app/api/endpoints/scripts.py:
-
Remove the
_require_team_adminfunction (lines 30-36). -
Add these imports at the top:
from app.core.permissions import can_manage_script_template, can_create_content
- Replace the
create_templateendpoint permission check. Change:
_require_team_admin(current_user)
to:
if not can_create_content(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Engineer access required to create templates",
)
- Replace the
update_templateendpoint. Change the permission check AND the query. The current query filters byteam_id == current_user.team_idwhich is too restrictive. Replace the full endpoint:
@router.put("/templates/{template_id}", response_model=ScriptTemplateDetail)
async def update_template(
template_id: UUID,
data: ScriptTemplateUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptTemplateDetail:
result = await db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == template_id,
ScriptTemplate.is_active == True, # noqa: E712
)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_manage_script_template(current_user, template.created_by, template.team_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to edit this template",
)
update_data = data.model_dump(exclude_unset=True)
if "script_body" in update_data or "parameters_schema" in update_data:
template.version += 1
for field, value in update_data.items():
setattr(template, field, value)
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)
- Replace the
delete_templateendpoint similarly:
@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
result = await db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == template_id,
ScriptTemplate.is_active == True, # noqa: E712
)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if not can_manage_script_template(current_user, template.created_by, template.team_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to delete this template",
)
template.is_active = False
await db.commit()
- Step 2: Add
managedquery param tolist_templates
In the list_templates endpoint, add a new query parameter and filter. Add after the existing tags param:
managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"),
Add this filter block after the existing search filter (before executing the query):
if managed:
if current_user.is_super_admin:
pass # super admin can edit all
elif current_user.account_role == "owner":
# owners see account-scoped templates
query = query.where(
or_(
ScriptTemplate.created_by == current_user.id,
ScriptTemplate.team_id != None, # noqa: E711
)
)
else:
# engineers see only their own
query = query.where(ScriptTemplate.created_by == current_user.id)
- Step 3: Add the share endpoint
Add at the end of backend/app/api/endpoints/scripts.py (before the generations section):
@router.patch("/templates/{template_id}/share", response_model=ScriptTemplateDetail)
async def share_template(
template_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
shared: bool = Query(..., description="true to share with team, false to make personal"),
) -> ScriptTemplateDetail:
"""Toggle team sharing for a template. Owner/admin/super_admin only."""
if not (current_user.is_super_admin or current_user.account_role == "owner"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only account owners and admins can share templates",
)
result = await db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == template_id,
ScriptTemplate.is_active == True, # noqa: E712
)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Template not found",
)
if shared:
template.team_id = current_user.team_id
else:
template.team_id = None
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)
- Step 4: Verify the backend starts
docker compose -f docker-compose.dev.yml restart backend && sleep 3 && curl -s http://localhost:8000/api/docs | head -5
Expected: Backend starts without import errors.
- Step 5: Commit
git add backend/app/api/endpoints/scripts.py
git commit -m "feat: refactor script template permissions — engineers manage own, add /share endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 4: Backend integration tests
Files:
-
Create:
backend/tests/test_script_templates.py -
Step 1: Write the test file
"""Integration tests for Script Template Editor permissions and share endpoint."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.script_template import ScriptCategory, ScriptTemplate
# ── Helpers ──────────────────────────────────────────────────────────────
async def _create_category(db: AsyncSession) -> ScriptCategory:
"""Seed a script category for tests."""
cat = ScriptCategory(name="Active Directory", slug="active-directory", sort_order=1)
db.add(cat)
await db.commit()
await db.refresh(cat)
return cat
async def _make_owner(db: AsyncSession, user_id: str) -> None:
"""Promote a user to account owner."""
from uuid import UUID as PyUUID
result = await db.execute(select(User).where(User.id == PyUUID(user_id)))
user = result.scalar_one()
user.account_role = "owner"
await db.commit()
async def _register_and_login(client: AsyncClient, email: str, password: str, name: str) -> tuple[dict, str]:
"""Register a user, login, return (user_data, access_token)."""
resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name})
assert resp.status_code in (200, 201)
user_data = resp.json()
login_resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password})
assert login_resp.status_code == 200
token = login_resp.json()["access_token"]
return user_data, token
TEMPLATE_PAYLOAD = {
"name": "Test Template",
"script_body": "Write-Host '{{ message }}'",
"parameters_schema": {
"parameters": [
{"key": "message", "label": "Message", "type": "text", "required": True, "order": 1}
]
},
"complexity": "beginner",
}
# ── Tests ────────────────────────────────────────────────────────────────
class TestScriptTemplatePermissions:
"""Test that engineers can create/edit their own templates, but not others'."""
@pytest.mark.asyncio
async def test_engineer_can_create_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Test Template"
assert data["created_by"] is not None
@pytest.mark.asyncio
async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
template_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Updated Template"},
headers=auth_headers,
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Updated Template"
@pytest.mark.asyncio
async def test_engineer_cannot_edit_others_template(self, client, test_db):
cat = await _create_category(test_db)
# Engineer A creates a template
_, token_a = await _register_and_login(client, "engineer_a@example.com", "TestPass123!", "Engineer A")
headers_a = {"Authorization": f"Bearer {token_a}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a)
template_id = create_resp.json()["id"]
# Engineer B tries to edit it
_, token_b = await _register_and_login(client, "engineer_b@example.com", "TestPass123!", "Engineer B")
headers_b = {"Authorization": f"Bearer {token_b}"}
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Hijacked!"},
headers=headers_b,
)
assert update_resp.status_code == 403
@pytest.mark.asyncio
async def test_engineer_can_delete_own_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
template_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers)
assert delete_resp.status_code == 204
@pytest.mark.asyncio
async def test_viewer_cannot_create_template(self, client, test_db):
from uuid import UUID as PyUUID
_, token = await _register_and_login(client, "viewer@example.com", "TestPass123!", "Viewer")
# Downgrade to viewer
result = await test_db.execute(select(User).where(User.email == "viewer@example.com"))
user = result.scalar_one()
user.role = "viewer"
user.account_role = "viewer"
await test_db.commit()
# Re-login to get new token with updated role
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "viewer@example.com", "password": "TestPass123!"})
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_admin_can_edit_others_template(self, client, test_db, admin_auth_headers):
cat = await _create_category(test_db)
# Create template as a regular engineer
_, token = await _register_and_login(client, "eng@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
# Admin edits it
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Admin Updated"},
headers=admin_auth_headers,
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Admin Updated"
@pytest.mark.asyncio
async def test_managed_filter_returns_own_templates(self, client, test_db):
cat = await _create_category(test_db)
# Engineer A creates a template
_, token_a = await _register_and_login(client, "eng_a2@example.com", "TestPass123!", "Eng A")
headers_a = {"Authorization": f"Bearer {token_a}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a)
# Engineer B should not see A's template in managed view
_, token_b = await _register_and_login(client, "eng_b2@example.com", "TestPass123!", "Eng B")
headers_b = {"Authorization": f"Bearer {token_b}"}
resp = await client.get("/api/v1/scripts/templates?managed=true", headers=headers_b)
assert resp.status_code == 200
assert len(resp.json()) == 0
class TestScriptTemplateShare:
"""Test the share/unshare endpoint."""
@pytest.mark.asyncio
async def test_owner_can_share_template(self, client, test_db):
cat = await _create_category(test_db)
# Create as engineer
user_data, token = await _register_and_login(client, "eng_share@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
assert create_resp.json()["team_id"] is None
# Promote to owner and re-login
await _make_owner(test_db, user_data["id"])
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_share@example.com", "password": "TestPass123!"})
owner_token = login_resp.json()["access_token"]
owner_headers = {"Authorization": f"Bearer {owner_token}"}
# Share
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=owner_headers,
)
assert share_resp.status_code == 200
assert share_resp.json()["team_id"] is not None
@pytest.mark.asyncio
async def test_engineer_cannot_share_template(self, client, test_db, auth_headers):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
template_id = create_resp.json()["id"]
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=auth_headers,
)
assert share_resp.status_code == 403
@pytest.mark.asyncio
async def test_owner_can_unshare_template(self, client, test_db):
cat = await _create_category(test_db)
user_data, token = await _register_and_login(client, "eng_unshare@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
# Promote to owner and re-login
await _make_owner(test_db, user_data["id"])
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_unshare@example.com", "password": "TestPass123!"})
owner_token = login_resp.json()["access_token"]
owner_headers = {"Authorization": f"Bearer {owner_token}"}
# Share then unshare
await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=owner_headers)
unshare_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=false",
headers=owner_headers,
)
assert unshare_resp.status_code == 200
assert unshare_resp.json()["team_id"] is None
- Step 2: Run the tests
docker exec resolutionflow_backend pytest tests/test_script_templates.py -v
Expected: All tests pass.
- Step 3: Commit
git add backend/tests/test_script_templates.py
git commit -m "test: add integration tests for script template permissions and share endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 2: Frontend — Types, API Client, Permissions
Task 5: Extend frontend types
Files:
-
Modify:
frontend/src/types/scripts.ts -
Step 1: Add
created_bytoScriptTemplateListItem
In frontend/src/types/scripts.ts, add created_by after the team_id field in ScriptTemplateListItem:
created_by: string | null
- Step 2: Add create/update request interfaces
Add at the end of frontend/src/types/scripts.ts:
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[]
}
- Step 3: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 4: Commit
git add frontend/src/types/scripts.ts
git commit -m "feat: add created_by and create/update request types for script templates
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 6: Extend API client
Files:
-
Modify:
frontend/src/api/scripts.ts -
Step 1: Add CRUD + share methods to
scriptsApi
Add the following imports to the top of frontend/src/api/scripts.ts:
import type {
ScriptCategoryResponse,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptGenerateRequest,
ScriptGenerateResponse,
ScriptGenerationRecord,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
} from '@/types'
Add these methods inside the scriptsApi object (before the closing }):
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
},
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/api/scripts.ts
git commit -m "feat: add CRUD and share methods to scriptsApi client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 7: Add permission check to usePermissions
Files:
-
Modify:
frontend/src/hooks/usePermissions.ts -
Step 1: Add
canManageScriptTemplateto the returned object
In frontend/src/hooks/usePermissions.ts, add inside the returned object (after canManageGlobalCategories):
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'),
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/hooks/usePermissions.ts
git commit -m "feat: add script template permission checks to usePermissions hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 3: Frontend — Script Template List View
Task 8: ScriptTemplateListView component
Files:
-
Create:
frontend/src/components/script-editor/ScriptTemplateListView.tsx -
Step 1: Create the component
import { useState, useEffect } from 'react'
import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode } from 'lucide-react'
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">
{/* 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>
)
}
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/components/script-editor/ScriptTemplateListView.tsx
git commit -m "feat: add ScriptTemplateListView component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 4: Frontend — Script Body Editor
Task 9: ScriptBodyEditor component
Files:
-
Create:
frontend/src/components/script-editor/ScriptBodyEditor.tsx -
Step 1: Create the component
import { useRef, useCallback } from 'react'
import { PowerShellHighlighter } from '@/components/scripts/PowerShellHighlighter'
interface Props {
value: string
onChange: (value: string) => void
disabled?: boolean
}
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleTab = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault()
const ta = e.currentTarget
const start = ta.selectionStart
const end = ta.selectionEnd
const newValue = value.substring(0, start) + ' ' + value.substring(end)
onChange(newValue)
// Restore cursor position after React re-render
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + 4
})
}
}, [value, onChange])
return (
<div className="relative rounded-xl border border-border overflow-hidden">
{/* Highlighted overlay (read-only visual layer) */}
<div className="absolute inset-0 pointer-events-none overflow-auto p-4">
<PowerShellHighlighter script={value || ' '} />
</div>
{/* Editable textarea (transparent text, visible caret) */}
<textarea
ref={textareaRef}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={handleTab}
disabled={disabled}
spellCheck={false}
className="relative z-10 w-full min-h-[300px] resize-y font-label text-sm bg-transparent text-transparent caret-foreground p-4 focus:outline-none focus:ring-1 focus:ring-[rgba(6,182,212,0.2)] disabled:cursor-not-allowed disabled:opacity-50"
placeholder="# Enter your PowerShell script here… # Use {{ param_name }} for parameter placeholders"
/>
</div>
)
}
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/components/script-editor/ScriptBodyEditor.tsx
git commit -m "feat: add ScriptBodyEditor with PowerShell syntax highlighting overlay
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 5: Frontend — Parameter Schema Builder
Task 10: ParameterCard component
Files:
-
Create:
frontend/src/components/script-editor/ParameterCard.tsx -
Step 1: Create the component
import { useState } from 'react'
import { ChevronDown, ChevronRight, GripVertical, Trash2, Plus, X } from 'lucide-react'
import { cn } from '@/lib/utils'
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>
)
}
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/components/script-editor/ParameterCard.tsx
git commit -m "feat: add ParameterCard component — expandable parameter editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 11: ParameterSchemaBuilder component
Files:
-
Create:
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx -
Step 1: Create the component
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]]
// Update order field
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)])
}
// Switch to JSON mode: serialize current schema
const switchToJson = () => {
setJsonText(JSON.stringify(schema, null, 2))
setJsonError(null)
setMode('json')
}
// Switch to visual mode: parse 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>
)
}
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
git commit -m "feat: add ParameterSchemaBuilder — visual builder + JSON toggle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 6: Frontend — Template Editor + Page Shell + Routing
Task 12: ScriptTemplateEditor component
Files:
-
Create:
frontend/src/components/script-editor/ScriptTemplateEditor.tsx -
Step 1: Create the component
import { useState, useEffect } from 'react'
import { ArrowLeft, Loader2, Save, Trash2 } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { usePermissions } from '@/hooks/usePermissions'
import { scriptsApi } from '@/api'
import { ScriptBodyEditor } from './ScriptBodyEditor'
import { ParameterSchemaBuilder } from './ParameterSchemaBuilder'
import type {
ScriptTemplateDetail,
ScriptCategoryResponse,
ScriptParametersSchema,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
} from '@/types'
interface Props {
templateId: string | null // null = create mode
onBack: () => void
onSaved: () => void
}
interface FormState {
name: string
description: string
use_case: string
category_id: string
complexity: 'beginner' | 'intermediate' | 'advanced'
tags: string
estimated_runtime: string
requires_elevation: boolean
requires_modules: string
script_body: string
parameters_schema: ScriptParametersSchema
}
const EMPTY_FORM: FormState = {
name: '',
description: '',
use_case: '',
category_id: '',
complexity: 'beginner',
tags: '',
estimated_runtime: '',
requires_elevation: false,
requires_modules: '',
script_body: '',
parameters_schema: { parameters: [] },
}
export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isLoading, setIsLoading] = useState(!!templateId)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
const { canShareScriptTemplate } = usePermissions()
// Load categories + template detail (if editing)
useEffect(() => {
const load = async () => {
try {
const cats = await scriptsApi.getCategories()
setCategories(cats)
if (templateId) {
const detail = await scriptsApi.getTemplateDetail(templateId)
setTemplate(detail)
const schema = detail.parameters_schema as ScriptParametersSchema
setForm({
name: detail.name,
description: detail.description ?? '',
use_case: detail.use_case ?? '',
category_id: detail.category_id,
complexity: detail.complexity,
tags: detail.tags.join(', '),
estimated_runtime: detail.estimated_runtime ?? '',
requires_elevation: detail.requires_elevation,
requires_modules: detail.requires_modules.join(', '),
script_body: detail.script_body,
parameters_schema: schema ?? { parameters: [] },
})
} else if (cats.length > 0) {
setForm(f => ({ ...f, category_id: cats[0].id }))
}
} catch {
setSaveError('Failed to load data')
} finally {
setIsLoading(false)
}
}
load()
}, [templateId])
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm(f => ({ ...f, [key]: value }))
setIsDirty(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
setSaveError('Name is required')
return
}
if (!form.script_body.trim()) {
setSaveError('Script body is required')
return
}
if (!form.category_id) {
setSaveError('Category is required')
return
}
setIsSaving(true)
setSaveError(null)
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean)
const requires_modules = form.requires_modules.split(',').map(m => m.trim()).filter(Boolean)
try {
if (templateId) {
const data: ScriptTemplateUpdateRequest = {
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.updateTemplate(templateId, data)
} else {
const data: ScriptTemplateCreateRequest = {
category_id: form.category_id,
name: form.name,
description: form.description || null,
use_case: form.use_case || null,
script_body: form.script_body,
parameters_schema: form.parameters_schema as unknown as ScriptParametersSchema,
tags,
complexity: form.complexity,
estimated_runtime: form.estimated_runtime || null,
requires_elevation: form.requires_elevation,
requires_modules,
}
await scriptsApi.createTemplate(data)
}
setIsDirty(false)
onSaved()
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setSaveError(axiosErr.response?.data?.detail ?? 'Failed to save template')
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!templateId) return
try {
await scriptsApi.deleteTemplate(templateId)
onSaved()
} catch {
setSaveError('Failed to delete template')
}
}
const handleShare = async (shared: boolean) => {
if (!templateId) return
try {
const updated = await scriptsApi.shareTemplate(templateId, shared)
setTemplate(updated)
} catch {
setSaveError('Failed to update sharing')
}
}
const handleBack = () => {
if (isDirty && !confirm('You have unsaved changes. Leave anyway?')) return
onBack()
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
)
}
return (
<div className="flex flex-col gap-6 pb-24">
{/* Back link */}
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<ArrowLeft size={12} />
Back to templates
</button>
<h1 className="text-2xl font-heading font-bold text-foreground">
{templateId ? 'Edit Template' : 'New Template'}
</h1>
{/* ── Metadata ──────────────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-4">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Metadata</p>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Name <span className="text-red-400">*</span>
</label>
<Input
value={form.name}
onChange={e => updateField('name', e.target.value)}
placeholder="e.g. Create AD User"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Description</label>
<Textarea
value={form.description}
onChange={e => updateField('description', e.target.value)}
placeholder="What does this script do?"
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Use Case</label>
<Textarea
value={form.use_case}
onChange={e => updateField('use_case', e.target.value)}
placeholder="When would you use this?"
rows={3}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">
Category <span className="text-red-400">*</span>
</label>
<select
value={form.category_id}
onChange={e => updateField('category_id', e.target.value)}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
>
<option value="">Select category…</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Complexity</label>
<select
value={form.complexity}
onChange={e => updateField('complexity', e.target.value as FormState['complexity'])}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Estimated Runtime</label>
<Input
value={form.estimated_runtime}
onChange={e => updateField('estimated_runtime', e.target.value)}
placeholder="e.g. 30 seconds"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Tags (comma-separated)</label>
<Input
value={form.tags}
onChange={e => updateField('tags', e.target.value)}
placeholder="active-directory, user, onboarding"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground mb-1 block">Required Modules (comma-separated)</label>
<Input
value={form.requires_modules}
onChange={e => updateField('requires_modules', e.target.value)}
placeholder="ActiveDirectory, GroupPolicy"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={form.requires_elevation}
onChange={e => updateField('requires_elevation', e.target.checked)}
className="rounded border-border"
/>
Requires elevation (Run as Administrator)
</label>
{/* Share toggle — only for owners/admins editing an existing template */}
{templateId && template && canShareScriptTemplate && (
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={template.team_id !== null}
onChange={e => handleShare(e.target.checked)}
className="rounded border-border"
/>
Share with team
<span className="text-xs text-muted-foreground">(visible to all team members)</span>
</label>
)}
</div>
</section>
{/* ── Script Body ───────────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-3">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Script Body <span className="text-red-400">*</span>
</p>
<p className="text-xs text-muted-foreground">
Use <code className="font-label text-amber-400">{'{{param_key}}'}</code> for parameter placeholders.
Supports <code className="font-label text-amber-400">{'{% if param %} ... {% endif %}'}</code> conditionals
and filters like <code className="font-label text-amber-400">{'{{ param | as_secure_string }}'}</code>.
</p>
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
</section>
{/* ── Parameters Schema ─────────────────────────────────────── */}
<section className="glass-card-static p-5 space-y-3">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Parameters</p>
<p className="text-xs text-muted-foreground">
Define form fields that users fill in when generating a script. Each parameter maps to a <code className="font-label text-amber-400">{'{{key}}'}</code> placeholder in the script body.
</p>
<ParameterSchemaBuilder
schema={form.parameters_schema}
onChange={v => updateField('parameters_schema', v)}
/>
</section>
{/* ── Fixed Action Bar ──────────────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-20 border-t border-border bg-background/80 backdrop-blur-sm px-6 py-3">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-5 py-2 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{templateId ? 'Save Changes' : 'Create Template'}
</button>
<button
type="button"
onClick={handleBack}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-4 py-2"
>
Cancel
</button>
</div>
{templateId && (
deleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-500">Delete this template?</span>
<button
type="button"
onClick={handleDelete}
className="text-xs font-label text-rose-500 hover:text-rose-400 px-2 py-1"
>
Confirm
</button>
<button
type="button"
onClick={() => setDeleteConfirm(false)}
className="text-xs font-label text-muted-foreground hover:text-foreground px-2 py-1"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteConfirm(true)}
className="flex items-center gap-1.5 text-sm text-rose-500 hover:text-rose-400 transition-colors px-3 py-2"
>
<Trash2 size={14} />
Delete
</button>
)
)}
</div>
</div>
{/* Save error */}
{saveError && (
<p className="text-sm text-rose-500 text-center">{saveError}</p>
)}
</div>
)
}
- Step 2: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -10
Expected: no errors.
- Step 3: Commit
git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx
git commit -m "feat: add ScriptTemplateEditor — full CRUD form with metadata, script body, and parameters
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Task 13: ScriptManagePage + Routing
Files:
-
Create:
frontend/src/pages/ScriptManagePage.tsx -
Modify:
frontend/src/router.tsx -
Modify:
frontend/src/pages/ScriptLibraryPage.tsx -
Step 1: Create
frontend/src/pages/ScriptManagePage.tsx
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="p-6 max-w-5xl mx-auto">
{mode === 'list' ? (
<ScriptTemplateListView onEdit={handleEdit} onCreate={handleCreate} />
) : (
<ScriptTemplateEditor
templateId={editingId}
onBack={handleBack}
onSaved={handleSaved}
/>
)}
</div>
)
}
- Step 2: Add lazy import and route to
frontend/src/router.tsx
After the ScriptLibraryPage import (line 44), add:
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
In the protected/AppLayout children array, after the scripts route (line 164), add:
{ path: 'scripts/manage', element: page(ScriptManagePage) },
- Step 3: Add "Manage Templates" link to
ScriptLibraryPage.tsx
In frontend/src/pages/ScriptLibraryPage.tsx, add these imports at the top:
import { Link } from 'react-router-dom'
import { Settings } from 'lucide-react'
Then in the page header <div> (after the <p> subtitle, around line 51), add:
{isEngineer && (
<Link
to="/scripts/manage"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-2"
>
<Settings size={12} />
Manage Templates
</Link>
)}
Note: isEngineer is already destructured from usePermissions() on the existing line 22.
- Step 4: Verify build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -20
Expected: clean build, no errors.
- Step 5: Commit
git add frontend/src/pages/ScriptManagePage.tsx frontend/src/router.tsx frontend/src/pages/ScriptLibraryPage.tsx
git commit -m "feat: add ScriptManagePage with routing and 'Manage Templates' link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Chunk 7: Smoke Test
Task 14: End-to-End Smoke Test
- Step 1: Rebuild containers
docker compose -f docker-compose.dev.yml up -d --build backend frontend
- Step 2: Verify backend tests pass
docker exec resolutionflow_backend pytest tests/test_script_templates.py -v
Expected: All tests pass.
- Step 3: Verify frontend build
docker exec resolutionflow_frontend npm run build 2>&1 | tail -20
Expected: Clean build.
- Step 4: Manual smoke test checklist
Open http://localhost:5173 and log in as engineer@resolutionflow.example.com (TestPass123!).
- Navigate to Script Library → click "Manage Templates" link
- Page loads at
/scripts/managewith empty list (or existing templates) - Click "New Template" → editor form appears
- Fill in: name, category, script body with
{{ param }}placeholder, add a parameter via visual builder - Click "Create Template" → redirects to list, new template appears
- Click Edit on the template → form pre-fills correctly
- Toggle to JSON mode in parameters → JSON appears, edit, toggle back → syncs
- Click "Save Changes" → success
- Click Delete → confirm → template removed from list
- Verify "Share with team" toggle is NOT visible (engineer role)
Then log in as admin@resolutionflow.example.com and verify:
11. "Share with team" toggle IS visible on edit form
12. Toggle share on → scope badge changes to "Team"
13. Toggle share off → reverts to "Personal"
- Step 5: Commit confirmation
git add -A
git commit -m "chore: script template editor smoke test complete
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
Done
All tasks complete. Push the branch:
git push origin feat/script-generator