# 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`: ```python 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: ```python from uuid import UUID ``` - [ ] **Step 2: Verify no import errors** ```bash docker exec resolutionflow_backend python -c "from app.core.permissions import can_manage_script_template; print('OK')" ``` Expected: `OK` - [ ] **Step 3: Commit** ```bash 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 " ``` --- ### Task 2: Add `created_by` to response schemas **Files:** - Modify: `backend/app/schemas/script_template.py` - [ ] **Step 1: Add `created_by` field to `ScriptTemplateListItem`** In `backend/app/schemas/script_template.py`, add `created_by` to `ScriptTemplateListItem` (line ~82, after `team_id`): ```python 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** ```bash 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** ```bash 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 " ``` --- ### Task 3: Refactor script endpoint permissions **Files:** - Modify: `backend/app/api/endpoints/scripts.py` - [ ] **Step 1: Replace `_require_team_admin` with new permission logic** In `backend/app/api/endpoints/scripts.py`: 1. Remove the `_require_team_admin` function (lines 30-36). 2. Add these imports at the top: ```python from app.core.permissions import can_manage_script_template, can_create_content ``` 3. Replace the `create_template` endpoint permission check. Change: ```python _require_team_admin(current_user) ``` to: ```python if not can_create_content(current_user): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Engineer access required to create templates", ) ``` 4. Replace the `update_template` endpoint. Change the permission check AND the query. The current query filters by `team_id == current_user.team_id` which is too restrictive. Replace the full endpoint: ```python @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) ``` 5. Replace the `delete_template` endpoint similarly: ```python @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 `managed` query param to `list_templates`** In the `list_templates` endpoint, add a new query parameter and filter. Add after the existing `tags` param: ```python 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): ```python 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): ```python @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** ```bash 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** ```bash 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 " ``` --- ### Task 4: Backend integration tests **Files:** - Create: `backend/tests/test_script_templates.py` - [ ] **Step 1: Write the test file** ```python """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** ```bash docker exec resolutionflow_backend pytest tests/test_script_templates.py -v ``` Expected: All tests pass. - [ ] **Step 3: Commit** ```bash 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 " ``` --- ## Chunk 2: Frontend — Types, API Client, Permissions ### Task 5: Extend frontend types **Files:** - Modify: `frontend/src/types/scripts.ts` - [ ] **Step 1: Add `created_by` to `ScriptTemplateListItem`** In `frontend/src/types/scripts.ts`, add `created_by` after the `team_id` field in `ScriptTemplateListItem`: ```typescript created_by: string | null ``` - [ ] **Step 2: Add create/update request interfaces** Add at the end of `frontend/src/types/scripts.ts`: ```typescript 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** ```bash docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash 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 " ``` --- ### 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`: ```typescript import type { ScriptCategoryResponse, ScriptTemplateListItem, ScriptTemplateDetail, ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord, ScriptTemplateCreateRequest, ScriptTemplateUpdateRequest, } from '@/types' ``` Add these methods inside the `scriptsApi` object (before the closing `}`): ```typescript async getManagedTemplates(params?: { category_slug?: string search?: string }): Promise { const response = await apiClient.get('/scripts/templates', { params: { ...params, managed: true }, }) return response.data }, async createTemplate(data: ScriptTemplateCreateRequest): Promise { const response = await apiClient.post('/scripts/templates', data) return response.data }, async updateTemplate(id: string, data: ScriptTemplateUpdateRequest): Promise { const response = await apiClient.put(`/scripts/templates/${id}`, data) return response.data }, async deleteTemplate(id: string): Promise { await apiClient.delete(`/scripts/templates/${id}`) }, async shareTemplate(id: string, shared: boolean): Promise { const response = await apiClient.patch( `/scripts/templates/${id}/share`, null, { params: { shared } }, ) return response.data }, ``` - [ ] **Step 2: Verify build** ```bash docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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 " ``` --- ### Task 7: Add permission check to usePermissions **Files:** - Modify: `frontend/src/hooks/usePermissions.ts` - [ ] **Step 1: Add `canManageScriptTemplate` to the returned object** In `frontend/src/hooks/usePermissions.ts`, add inside the returned object (after `canManageGlobalCategories`): ```typescript 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** ```bash docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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 " ``` --- ## 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** ```tsx 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([]) const [categories, setCategories] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [deleteConfirm, setDeleteConfirm] = useState(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 (
{/* Header row */}

Manage Templates

Create and edit PowerShell script templates.

{canCreateScriptTemplate && ( )}
{/* Search */}
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)]" />
{/* Template list */} {isLoading ? (
) : templates.length === 0 ? (

{searchQuery ? 'No templates match your search' : 'No templates yet. Create your first one!'}

) : (
{templates.map(t => ( ))}
Name Category Complexity Scope Uses Actions
{t.name} {t.description && (

{t.description}

)}
{getCategoryName(t.category_id)} {t.complexity} {t.team_id ? <> Team : <> Personal} {t.usage_count}
{canManageScriptTemplate(t) && ( <> {deleteConfirm === t.id ? (
) : ( )} )}
)}
) } ``` - [ ] **Step 2: Verify build** ```bash docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add frontend/src/components/script-editor/ScriptTemplateListView.tsx git commit -m "feat: add ScriptTemplateListView component Co-Authored-By: Claude Opus 4.6 " ``` --- ## Chunk 4: Frontend — Script Body Editor ### Task 9: ScriptBodyEditor component **Files:** - Create: `frontend/src/components/script-editor/ScriptBodyEditor.tsx` - [ ] **Step 1: Create the component** ```tsx 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(null) const handleTab = useCallback((e: React.KeyboardEvent) => { 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 (
{/* Highlighted overlay (read-only visual layer) */}
{/* Editable textarea (transparent text, visible caret) */}