Files
resolutionflow/docs/plans/archive/2026-03-13-script-template-editor-impl.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00

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_by field to ScriptTemplateListItem

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_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:

from app.core.permissions import can_manage_script_template, can_create_content
  1. Replace the create_template endpoint 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",
    )
  1. 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:
@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)
  1. Replace the delete_template endpoint 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 managed query param to list_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_by to ScriptTemplateListItem

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 canManageScriptTemplate to 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…&#10;# 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 &#123;&#123;key&#125;&#125;)</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!).

  1. Navigate to Script Library → click "Manage Templates" link
  2. Page loads at /scripts/manage with empty list (or existing templates)
  3. Click "New Template" → editor form appears
  4. Fill in: name, category, script body with {{ param }} placeholder, add a parameter via visual builder
  5. Click "Create Template" → redirects to list, new template appears
  6. Click Edit on the template → form pre-fills correctly
  7. Toggle to JSON mode in parameters → JSON appears, edit, toggle back → syncs
  8. Click "Save Changes" → success
  9. Click Delete → confirm → template removed from list
  10. 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