From 8251b085175a0d22922386d1007d3eac907ac60e Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 13 Mar 2026 18:44:20 -0400 Subject: [PATCH] docs: add Script Template Editor implementation plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-script-template-editor-impl.md | 2253 +++++++++++++++++ 1 file changed, 2253 insertions(+) create mode 100644 docs/plans/2026-03-13-script-template-editor-impl.md diff --git a/docs/plans/2026-03-13-script-template-editor-impl.md b/docs/plans/2026-03-13-script-template-editor-impl.md new file mode 100644 index 00000000..08ff8a77 --- /dev/null +++ b/docs/plans/2026-03-13-script-template-editor-impl.md @@ -0,0 +1,2253 @@ +# Script Template Editor — Implementation Plan + +> **For Claude:** REQUIRED: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Script Template Editor page (`/scripts/manage`) where engineers create personal templates, owners/admins can edit any and toggle "Share with team", and super admins have full access. + +**Architecture:** Refactor backend permission checks on existing CRUD endpoints to allow engineers to manage their own templates. Add `PATCH /share` endpoint. Build a new frontend page with list/editor modes, a visual parameter schema builder with JSON toggle, and a PowerShell script body editor. + +**Tech Stack:** Python FastAPI, SQLAlchemy 2.0, Pydantic v2, React 19, TypeScript, Zustand, Axios (`apiClient`), Tailwind CSS v3, Lucide React. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `backend/app/api/endpoints/scripts.py` | Modify | Refactor permissions, add `/share` endpoint, add `managed` filter | +| `backend/app/schemas/script_template.py` | Modify | Add `created_by` to response schemas | +| `backend/app/core/permissions.py` | Modify | Add `can_manage_script_template()` | +| `backend/tests/test_script_templates.py` | Create | Integration tests for permission changes + share endpoint | +| `frontend/src/types/scripts.ts` | Modify | Add `created_by` to list/detail types, add create/update request types | +| `frontend/src/api/scripts.ts` | Modify | Add `createTemplate`, `updateTemplate`, `deleteTemplate`, `shareTemplate`, `getManagedTemplates` | +| `frontend/src/hooks/usePermissions.ts` | Modify | Add `canManageScriptTemplate()` check | +| `frontend/src/pages/ScriptManagePage.tsx` | Create | Page shell — list/editor mode toggle | +| `frontend/src/components/script-editor/ScriptTemplateListView.tsx` | Create | Template list with filters, search, scope badges, actions | +| `frontend/src/components/script-editor/ScriptTemplateEditor.tsx` | Create | Full editor form — metadata, script body, parameters, actions | +| `frontend/src/components/script-editor/ScriptBodyEditor.tsx` | Create | Textarea with PowerShell highlighting overlay | +| `frontend/src/components/script-editor/ParameterSchemaBuilder.tsx` | Create | Visual parameter builder + JSON toggle | +| `frontend/src/components/script-editor/ParameterCard.tsx` | Create | Single parameter editor (expandable card) | +| `frontend/src/router.tsx` | Modify | Add `/scripts/manage` route | +| `frontend/src/pages/ScriptLibraryPage.tsx` | Modify | Add "Manage Templates" link for engineers+ | + +--- + +## Chunk 1: Backend — Permission Refactor + Share Endpoint + +### Task 1: Add `can_manage_script_template` to permissions.py + +**Files:** +- Modify: `backend/app/core/permissions.py` + +- [ ] **Step 1: Add the permission function** + +Add at the end of `backend/app/core/permissions.py`: + +```python +def can_manage_script_template(user: User, template_created_by: Optional[UUID], template_account_id: Optional[UUID] = None) -> bool: + """Can the user edit/delete this script template? + + - Super admins can manage any template + - Account owners can manage any template in their account + - Engineers can manage templates they created + """ + if user.is_super_admin: + return True + if user.account_role == "owner" and template_account_id == user.account_id and user.account_id is not None: + return True + if template_created_by == user.id: + return True + return False +``` + +Add the import at the top if not present: +```python +from uuid import UUID +``` + +- [ ] **Step 2: Verify no import errors** + +```bash +docker exec resolutionflow_backend python -c "from app.core.permissions import can_manage_script_template; print('OK')" +``` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/permissions.py +git commit -m "feat: add can_manage_script_template permission check + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 2: Add `created_by` to response schemas + +**Files:** +- Modify: `backend/app/schemas/script_template.py` + +- [ ] **Step 1: Add `created_by` field to `ScriptTemplateListItem`** + +In `backend/app/schemas/script_template.py`, add `created_by` to `ScriptTemplateListItem` (line ~82, after `team_id`): + +```python +class ScriptTemplateListItem(BaseModel): + id: UUID + category_id: UUID + team_id: Optional[UUID] = None + created_by: Optional[UUID] = None # ← ADD THIS LINE + name: str + slug: str + # ... rest stays the same +``` + +- [ ] **Step 2: Verify import works** + +```bash +docker exec resolutionflow_backend python -c "from app.schemas.script_template import ScriptTemplateListItem; print(ScriptTemplateListItem.model_fields.keys())" +``` +Expected: output includes `created_by` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/schemas/script_template.py +git commit -m "feat: expose created_by in script template response schemas + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 3: Refactor script endpoint permissions + +**Files:** +- Modify: `backend/app/api/endpoints/scripts.py` + +- [ ] **Step 1: Replace `_require_team_admin` with new permission logic** + +In `backend/app/api/endpoints/scripts.py`: + +1. Remove the `_require_team_admin` function (lines 30-36). + +2. Add these imports at the top: +```python +from app.core.permissions import can_manage_script_template, can_create_content +``` + +3. Replace the `create_template` endpoint permission check. Change: +```python +_require_team_admin(current_user) +``` +to: +```python +if not can_create_content(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Engineer access required to create templates", + ) +``` + +4. Replace the `update_template` endpoint. Change the permission check AND the query. The current query filters by `team_id == current_user.team_id` which is too restrictive. Replace the full endpoint: + +```python +@router.put("/templates/{template_id}", response_model=ScriptTemplateDetail) +async def update_template( + template_id: UUID, + data: ScriptTemplateUpdate, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> ScriptTemplateDetail: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to edit this template", + ) + + update_data = data.model_dump(exclude_unset=True) + if "script_body" in update_data or "parameters_schema" in update_data: + template.version += 1 + for field, value in update_data.items(): + setattr(template, field, value) + + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) +``` + +5. Replace the `delete_template` endpoint similarly: + +```python +@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> None: + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if not can_manage_script_template(current_user, template.created_by, template.team_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You don't have permission to delete this template", + ) + + template.is_active = False + await db.commit() +``` + +- [ ] **Step 2: Add `managed` query param to `list_templates`** + +In the `list_templates` endpoint, add a new query parameter and filter. Add after the existing `tags` param: + +```python +managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"), +``` + +Add this filter block after the existing `search` filter (before executing the query): + +```python +if managed: + if current_user.is_super_admin: + pass # super admin can edit all + elif current_user.account_role == "owner": + # owners see account-scoped templates + query = query.where( + or_( + ScriptTemplate.created_by == current_user.id, + ScriptTemplate.team_id != None, # noqa: E711 + ) + ) + else: + # engineers see only their own + query = query.where(ScriptTemplate.created_by == current_user.id) +``` + +- [ ] **Step 3: Add the share endpoint** + +Add at the end of `backend/app/api/endpoints/scripts.py` (before the generations section): + +```python +@router.patch("/templates/{template_id}/share", response_model=ScriptTemplateDetail) +async def share_template( + template_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], + shared: bool = Query(..., description="true to share with team, false to make personal"), +) -> ScriptTemplateDetail: + """Toggle team sharing for a template. Owner/admin/super_admin only.""" + if not (current_user.is_super_admin or current_user.account_role == "owner"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only account owners and admins can share templates", + ) + + result = await db.execute( + select(ScriptTemplate).where( + ScriptTemplate.id == template_id, + ScriptTemplate.is_active == True, # noqa: E712 + ) + ) + template = result.scalar_one_or_none() + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found", + ) + + if shared: + template.team_id = current_user.team_id + else: + template.team_id = None + + await db.commit() + await db.refresh(template) + return ScriptTemplateDetail.model_validate(template) +``` + +- [ ] **Step 4: Verify the backend starts** + +```bash +docker compose -f docker-compose.dev.yml restart backend && sleep 3 && curl -s http://localhost:8000/api/docs | head -5 +``` +Expected: Backend starts without import errors. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/endpoints/scripts.py +git commit -m "feat: refactor script template permissions — engineers manage own, add /share endpoint + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 4: Backend integration tests + +**Files:** +- Create: `backend/tests/test_script_templates.py` + +- [ ] **Step 1: Write the test file** + +```python +"""Integration tests for Script Template Editor permissions and share endpoint.""" +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.models.script_template import ScriptCategory, ScriptTemplate + + +# ── Helpers ────────────────────────────────────────────────────────────── + +async def _create_category(db: AsyncSession) -> ScriptCategory: + """Seed a script category for tests.""" + cat = ScriptCategory(name="Active Directory", slug="active-directory", sort_order=1) + db.add(cat) + await db.commit() + await db.refresh(cat) + return cat + + +async def _make_owner(db: AsyncSession, user_id: str) -> None: + """Promote a user to account owner.""" + from uuid import UUID as PyUUID + result = await db.execute(select(User).where(User.id == PyUUID(user_id))) + user = result.scalar_one() + user.account_role = "owner" + await db.commit() + + +async def _register_and_login(client: AsyncClient, email: str, password: str, name: str) -> tuple[dict, str]: + """Register a user, login, return (user_data, access_token).""" + resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name}) + assert resp.status_code in (200, 201) + user_data = resp.json() + login_resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password}) + assert login_resp.status_code == 200 + token = login_resp.json()["access_token"] + return user_data, token + + +TEMPLATE_PAYLOAD = { + "name": "Test Template", + "script_body": "Write-Host '{{ message }}'", + "parameters_schema": { + "parameters": [ + {"key": "message", "label": "Message", "type": "text", "required": True, "order": 1} + ] + }, + "complexity": "beginner", +} + + +# ── Tests ──────────────────────────────────────────────────────────────── + +class TestScriptTemplatePermissions: + """Test that engineers can create/edit their own templates, but not others'.""" + + @pytest.mark.asyncio + async def test_engineer_can_create_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Template" + assert data["created_by"] is not None + + @pytest.mark.asyncio + async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Updated Template"}, + headers=auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Updated Template" + + @pytest.mark.asyncio + async def test_engineer_cannot_edit_others_template(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "engineer_a@example.com", "TestPass123!", "Engineer A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + template_id = create_resp.json()["id"] + + # Engineer B tries to edit it + _, token_b = await _register_and_login(client, "engineer_b@example.com", "TestPass123!", "Engineer B") + headers_b = {"Authorization": f"Bearer {token_b}"} + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Hijacked!"}, + headers=headers_b, + ) + assert update_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_engineer_can_delete_own_template(self, client, auth_headers, test_db): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + delete_resp = await client.delete(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers) + assert delete_resp.status_code == 204 + + @pytest.mark.asyncio + async def test_viewer_cannot_create_template(self, client, test_db): + from uuid import UUID as PyUUID + _, token = await _register_and_login(client, "viewer@example.com", "TestPass123!", "Viewer") + # Downgrade to viewer + result = await test_db.execute(select(User).where(User.email == "viewer@example.com")) + user = result.scalar_one() + user.role = "viewer" + user.account_role = "viewer" + await test_db.commit() + + # Re-login to get new token with updated role + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "viewer@example.com", "password": "TestPass123!"}) + token = login_resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_admin_can_edit_others_template(self, client, test_db, admin_auth_headers): + cat = await _create_category(test_db) + # Create template as a regular engineer + _, token = await _register_and_login(client, "eng@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Admin edits it + update_resp = await client.put( + f"/api/v1/scripts/templates/{template_id}", + json={"name": "Admin Updated"}, + headers=admin_auth_headers, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["name"] == "Admin Updated" + + @pytest.mark.asyncio + async def test_managed_filter_returns_own_templates(self, client, test_db): + cat = await _create_category(test_db) + + # Engineer A creates a template + _, token_a = await _register_and_login(client, "eng_a2@example.com", "TestPass123!", "Eng A") + headers_a = {"Authorization": f"Bearer {token_a}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a) + + # Engineer B should not see A's template in managed view + _, token_b = await _register_and_login(client, "eng_b2@example.com", "TestPass123!", "Eng B") + headers_b = {"Authorization": f"Bearer {token_b}"} + + resp = await client.get("/api/v1/scripts/templates?managed=true", headers=headers_b) + assert resp.status_code == 200 + assert len(resp.json()) == 0 + + +class TestScriptTemplateShare: + """Test the share/unshare endpoint.""" + + @pytest.mark.asyncio + async def test_owner_can_share_template(self, client, test_db): + cat = await _create_category(test_db) + + # Create as engineer + user_data, token = await _register_and_login(client, "eng_share@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + assert create_resp.json()["team_id"] is None + + # Promote to owner and re-login + await _make_owner(test_db, user_data["id"]) + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_share@example.com", "password": "TestPass123!"}) + owner_token = login_resp.json()["access_token"] + owner_headers = {"Authorization": f"Bearer {owner_token}"} + + # Share + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=owner_headers, + ) + assert share_resp.status_code == 200 + assert share_resp.json()["team_id"] is not None + + @pytest.mark.asyncio + async def test_engineer_cannot_share_template(self, client, test_db, auth_headers): + cat = await _create_category(test_db) + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers) + template_id = create_resp.json()["id"] + + share_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=true", + headers=auth_headers, + ) + assert share_resp.status_code == 403 + + @pytest.mark.asyncio + async def test_owner_can_unshare_template(self, client, test_db): + cat = await _create_category(test_db) + + user_data, token = await _register_and_login(client, "eng_unshare@example.com", "TestPass123!", "Eng") + headers = {"Authorization": f"Bearer {token}"} + payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)} + create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers) + template_id = create_resp.json()["id"] + + # Promote to owner and re-login + await _make_owner(test_db, user_data["id"]) + login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_unshare@example.com", "password": "TestPass123!"}) + owner_token = login_resp.json()["access_token"] + owner_headers = {"Authorization": f"Bearer {owner_token}"} + + # Share then unshare + await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=owner_headers) + unshare_resp = await client.patch( + f"/api/v1/scripts/templates/{template_id}/share?shared=false", + headers=owner_headers, + ) + assert unshare_resp.status_code == 200 + assert unshare_resp.json()["team_id"] is None +``` + +- [ ] **Step 2: Run the tests** + +```bash +docker exec resolutionflow_backend pytest tests/test_script_templates.py -v +``` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/test_script_templates.py +git commit -m "test: add integration tests for script template permissions and share endpoint + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 2: Frontend — Types, API Client, Permissions + +### Task 5: Extend frontend types + +**Files:** +- Modify: `frontend/src/types/scripts.ts` + +- [ ] **Step 1: Add `created_by` to `ScriptTemplateListItem`** + +In `frontend/src/types/scripts.ts`, add `created_by` after the `team_id` field in `ScriptTemplateListItem`: + +```typescript +created_by: string | null +``` + +- [ ] **Step 2: Add create/update request interfaces** + +Add at the end of `frontend/src/types/scripts.ts`: + +```typescript +export interface ScriptTemplateCreateRequest { + category_id: string + name: string + description?: string | null + use_case?: string | null + script_body: string + parameters_schema: ScriptParametersSchema + tags?: string[] + complexity?: 'beginner' | 'intermediate' | 'advanced' + estimated_runtime?: string | null + requires_elevation?: boolean + requires_modules?: string[] +} + +export interface ScriptTemplateUpdateRequest { + name?: string + description?: string | null + use_case?: string | null + script_body?: string + parameters_schema?: ScriptParametersSchema + tags?: string[] + complexity?: 'beginner' | 'intermediate' | 'advanced' + estimated_runtime?: string | null + requires_elevation?: boolean + requires_modules?: string[] +} +``` + +- [ ] **Step 3: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/scripts.ts +git commit -m "feat: add created_by and create/update request types for script templates + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 6: Extend API client + +**Files:** +- Modify: `frontend/src/api/scripts.ts` + +- [ ] **Step 1: Add CRUD + share methods to `scriptsApi`** + +Add the following imports to the top of `frontend/src/api/scripts.ts`: + +```typescript +import type { + ScriptCategoryResponse, + ScriptTemplateListItem, + ScriptTemplateDetail, + ScriptGenerateRequest, + ScriptGenerateResponse, + ScriptGenerationRecord, + ScriptTemplateCreateRequest, + ScriptTemplateUpdateRequest, +} from '@/types' +``` + +Add these methods inside the `scriptsApi` object (before the closing `}`): + +```typescript + async getManagedTemplates(params?: { + category_slug?: string + search?: string + }): Promise { + const response = await apiClient.get('/scripts/templates', { + params: { ...params, managed: true }, + }) + return response.data + }, + + async createTemplate(data: ScriptTemplateCreateRequest): Promise { + const response = await apiClient.post('/scripts/templates', data) + return response.data + }, + + async updateTemplate(id: string, data: ScriptTemplateUpdateRequest): Promise { + const response = await apiClient.put(`/scripts/templates/${id}`, data) + return response.data + }, + + async deleteTemplate(id: string): Promise { + await apiClient.delete(`/scripts/templates/${id}`) + }, + + async shareTemplate(id: string, shared: boolean): Promise { + const response = await apiClient.patch( + `/scripts/templates/${id}/share`, + null, + { params: { shared } }, + ) + return response.data + }, +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/scripts.ts +git commit -m "feat: add CRUD and share methods to scriptsApi client + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +### Task 7: Add permission check to usePermissions + +**Files:** +- Modify: `frontend/src/hooks/usePermissions.ts` + +- [ ] **Step 1: Add `canManageScriptTemplate` to the returned object** + +In `frontend/src/hooks/usePermissions.ts`, add inside the returned object (after `canManageGlobalCategories`): + +```typescript + canManageScriptTemplate: (template: { created_by: string | null; team_id?: string | null }) => { + if (!user) return false + if (user.is_super_admin) return true + if (user.account_role === 'owner') return true + return template.created_by === user.id + }, + + canShareScriptTemplate: effectiveRole === 'super_admin' || effectiveRole === 'owner', + + canCreateScriptTemplate: hasMinimumRole(user, 'engineer'), +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/hooks/usePermissions.ts +git commit -m "feat: add script template permission checks to usePermissions hook + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 3: Frontend — Script Template List View + +### Task 8: ScriptTemplateListView component + +**Files:** +- Create: `frontend/src/components/script-editor/ScriptTemplateListView.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import { useState, useEffect } from 'react' +import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode } from 'lucide-react' +import { cn } from '@/lib/utils' +import { usePermissions } from '@/hooks/usePermissions' +import { scriptsApi } from '@/api' +import type { ScriptTemplateListItem, ScriptCategoryResponse } from '@/types' + +const COMPLEXITY_CLASSES = { + beginner: 'text-emerald-400 bg-emerald-400/10', + intermediate: 'text-amber-400 bg-amber-400/10', + advanced: 'text-rose-500 bg-rose-500/10', +} as const + +interface Props { + onEdit: (id: string) => void + onCreate: () => void +} + +export function ScriptTemplateListView({ onEdit, onCreate }: Props) { + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const { canManageScriptTemplate, canCreateScriptTemplate } = usePermissions() + + const loadData = async () => { + setIsLoading(true) + try { + const [tpls, cats] = await Promise.all([ + scriptsApi.getManagedTemplates(searchQuery ? { search: searchQuery } : undefined), + scriptsApi.getCategories(), + ]) + setTemplates(tpls) + setCategories(cats) + } catch { + // silently fail + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + loadData() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const timer = setTimeout(() => { + loadData() + }, 300) + return () => clearTimeout(timer) + }, [searchQuery]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDelete = async (id: string) => { + try { + await scriptsApi.deleteTemplate(id) + setTemplates(prev => prev.filter(t => t.id !== id)) + setDeleteConfirm(null) + } catch { + // silently fail + } + } + + const getCategoryName = (categoryId: string) => + categories.find(c => c.id === categoryId)?.name ?? 'Unknown' + + return ( +
+ {/* Header row */} +
+
+

Manage Templates

+

+ Create and edit PowerShell script templates. +

+
+ {canCreateScriptTemplate && ( + + )} +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search templates…" + className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] focus:ring-1 focus:ring-[rgba(6,182,212,0.2)]" + /> +
+ + {/* Template list */} + {isLoading ? ( +
+ +
+ ) : templates.length === 0 ? ( +
+ +

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

+
+ ) : ( +
+ + + + + + + + + + + + + {templates.map(t => ( + + + + + + + + + ))} + +
NameCategoryComplexityScopeUsesActions
+ {t.name} + {t.description && ( +

{t.description}

+ )} +
{getCategoryName(t.category_id)} + + {t.complexity} + + + + {t.team_id ? <> Team : <> Personal} + + {t.usage_count} +
+ {canManageScriptTemplate(t) && ( + <> + + {deleteConfirm === t.id ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Verify build** + +```bash +docker exec resolutionflow_frontend npm run build 2>&1 | tail -10 +``` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/script-editor/ScriptTemplateListView.tsx +git commit -m "feat: add ScriptTemplateListView component + +Co-Authored-By: Claude Opus 4.6 " +``` + +--- + +## Chunk 4: Frontend — Script Body Editor + +### Task 9: ScriptBodyEditor component + +**Files:** +- Create: `frontend/src/components/script-editor/ScriptBodyEditor.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import { useRef, useCallback } from 'react' +import { PowerShellHighlighter } from '@/components/scripts/PowerShellHighlighter' + +interface Props { + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export function ScriptBodyEditor({ value, onChange, disabled }: Props) { + const textareaRef = useRef(null) + + const handleTab = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Tab') { + e.preventDefault() + const ta = e.currentTarget + const start = ta.selectionStart + const end = ta.selectionEnd + const newValue = value.substring(0, start) + ' ' + value.substring(end) + onChange(newValue) + // Restore cursor position after React re-render + requestAnimationFrame(() => { + ta.selectionStart = ta.selectionEnd = start + 4 + }) + } + }, [value, onChange]) + + return ( +
+ {/* Highlighted overlay (read-only visual layer) */} +
+ +
+ + {/* Editable textarea (transparent text, visible caret) */} +