Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2254 lines
79 KiB
Markdown
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… # 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 {{key}})</label>
|
|
<Input
|
|
value={param.key}
|
|
onChange={e => update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
|
placeholder="param_key"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
|
|
<Input
|
|
value={param.label}
|
|
onChange={e => update({ label: e.target.value })}
|
|
placeholder="Display Label"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: type + group */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Type</label>
|
|
<select
|
|
value={param.type}
|
|
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
|
|
disabled={disabled}
|
|
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{PARAM_TYPES.map(t => (
|
|
<option key={t.value} value={t.value}>{t.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Group (optional)</label>
|
|
<Input
|
|
value={param.group ?? ''}
|
|
onChange={e => update({ group: e.target.value || null })}
|
|
placeholder="e.g. User Identity"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: placeholder + help text */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Placeholder</label>
|
|
<Input
|
|
value={param.placeholder ?? ''}
|
|
onChange={e => update({ placeholder: e.target.value || null })}
|
|
placeholder="Placeholder text"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Help text</label>
|
|
<Input
|
|
value={param.help_text ?? ''}
|
|
onChange={e => update({ help_text: e.target.value || null })}
|
|
placeholder="Help text shown below field"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 4: toggles */}
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={param.required}
|
|
onChange={e => update({ required: e.target.checked })}
|
|
disabled={disabled}
|
|
className="rounded border-border"
|
|
/>
|
|
Required
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={param.sensitive}
|
|
onChange={e => update({ sensitive: e.target.checked })}
|
|
disabled={disabled}
|
|
className="rounded border-border"
|
|
/>
|
|
Sensitive (redacted in logs)
|
|
</label>
|
|
</div>
|
|
|
|
{/* Default value */}
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
|
|
<Input
|
|
value={param.default !== null && param.default !== undefined ? String(param.default) : ''}
|
|
onChange={e => update({ default: e.target.value || null })}
|
|
placeholder="Default value"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
|
|
{/* Select options (only for select type) */}
|
|
{param.type === 'select' && (
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Options</label>
|
|
<div className="space-y-1.5">
|
|
{(param.options ?? []).map((opt, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<Input
|
|
value={opt.value}
|
|
onChange={e => updateOption(i, { value: e.target.value })}
|
|
placeholder="value"
|
|
disabled={disabled}
|
|
/>
|
|
<Input
|
|
value={opt.label}
|
|
onChange={e => updateOption(i, { label: e.target.value })}
|
|
placeholder="label"
|
|
disabled={disabled}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeOption(i)}
|
|
disabled={disabled}
|
|
className="p-1 text-muted-foreground hover:text-rose-500 transition-colors"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={addOption}
|
|
disabled={disabled}
|
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
|
>
|
|
<Plus size={12} /> Add option
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validation (for text/number types) */}
|
|
{(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && (
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">Validation (optional)</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{param.type === 'number' ? (
|
|
<>
|
|
<div>
|
|
<label className="text-[0.625rem] text-muted-foreground">Min value</label>
|
|
<Input
|
|
type="number"
|
|
value={param.validation?.min_value ?? ''}
|
|
onChange={e => updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[0.625rem] text-muted-foreground">Max value</label>
|
|
<Input
|
|
type="number"
|
|
value={param.validation?.max_value ?? ''}
|
|
onChange={e => updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<label className="text-[0.625rem] text-muted-foreground">Min length</label>
|
|
<Input
|
|
type="number"
|
|
value={param.validation?.min_length ?? ''}
|
|
onChange={e => updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[0.625rem] text-muted-foreground">Max length</label>
|
|
<Input
|
|
type="number"
|
|
value={param.validation?.max_length ?? ''}
|
|
onChange={e => updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div>
|
|
<label className="text-[0.625rem] text-muted-foreground">Pattern (regex)</label>
|
|
<Input
|
|
value={param.validation?.pattern ?? ''}
|
|
onChange={e => updateValidation({ pattern: e.target.value || undefined })}
|
|
placeholder="^[a-z]+$"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions row */}
|
|
<div className="flex items-center justify-between pt-1 border-t border-border">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => onMoveUp(index)}
|
|
disabled={isFirst || disabled}
|
|
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
|
>
|
|
↑ Up
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onMoveDown(index)}
|
|
disabled={isLast || disabled}
|
|
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
|
>
|
|
↓ Down
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRemove(index)}
|
|
disabled={disabled}
|
|
className="flex items-center gap-1 text-xs text-rose-500 hover:text-rose-400 transition-colors px-1.5 py-0.5"
|
|
>
|
|
<Trash2 size={12} /> Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```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
|
|
```
|