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

2254 lines
79 KiB
Markdown

# 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 <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`):
```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 <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:
```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 <noreply@anthropic.com>"
```
---
### 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 <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`:
```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 <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`:
```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<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**
```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 <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`):
```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 <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**
```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<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**
```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 <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**
```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<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**
```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/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**
```tsx
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**
```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/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**
```tsx
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**
```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/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**
```tsx
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**
```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/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`**
```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:
```typescript
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
```
In the protected/AppLayout children array, after the `scripts` route (line 164), add:
```typescript
{ 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:
```typescript
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:
```tsx
{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**
```bash
docker exec resolutionflow_frontend npm run build 2>&1 | tail -20
```
Expected: clean build, no errors.
- [ ] **Step 5: Commit**
```bash
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**
```bash
docker compose -f docker-compose.dev.yml up -d --build backend frontend
```
- [ ] **Step 2: Verify backend tests pass**
```bash
docker exec resolutionflow_backend pytest tests/test_script_templates.py -v
```
Expected: All tests pass.
- [ ] **Step 3: Verify frontend build**
```bash
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**
```bash
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:
```bash
git push origin feat/script-generator
```