feat: Script Generator Phase 1 — backend models, engine, API, and AD templates #105

Merged
chihlasm merged 78 commits from feat/script-generator into main 2026-03-15 00:19:00 +00:00
50 changed files with 11916 additions and 11 deletions

View File

@@ -30,6 +30,7 @@
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
**Component styling rules:**
- Primary buttons: `bg-gradient-brand` (cyan `135deg`) with `shadow-lg shadow-primary/20`, hover `opacity-0.9`, active `scale(0.97)`
- Secondary buttons: `bg-[rgba(255,255,255,0.04)]` with `border-[rgba(255,255,255,0.06)]`, hover brightens border
- Active nav items: `bg-primary/10` background + 3px left cyan gradient accent bar
@@ -47,6 +48,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
- Prefer correct architecture over minimal diff
- If two approaches exist, implement the one that scales, not the one that's faster to write
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
---
## Current State
@@ -77,6 +79,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
## Tech Stack
### Backend
- **Framework:** Python FastAPI
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
- **Migrations:** Alembic
@@ -85,6 +88,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
### Frontend
- **Framework:** React 19 + Vite + TypeScript
- **Styling:** Tailwind CSS v3 — dark-first with purple gradient accents (see Branding section)
- **State:** Zustand (with immer + zundo for undo/redo)
@@ -131,6 +135,7 @@ patherly/
## Environment Variables
### Backend (`backend/.env`)
```bash
APP_NAME=ResolutionFlow
DEBUG=true
@@ -143,12 +148,54 @@ REQUIRE_INVITE_CODE=true
```
### Frontend (`frontend/.env.local` - optional)
```bash
VITE_API_URL=http://localhost:8000
```
---
## ConnectWise PSA Integration
ResolutionFlow integrates with ConnectWise PSA (formerly Manage) as the primary PSA integration. All ConnectWise API reference materials live in `docs/connectwise/`.
### Best Practices Documentation
Official ConnectWise developer guides live in `docs/connectwise/best-practices/`. Read these BEFORE implementing any CW API integration code:
- `PSA-API-Requests.md` — HTTP methods, response codes, condition query syntax, PATCH format, URL encoding, partial responses, custom fields. READ FIRST.
- `PSA-Callbacks.md` — Callback type/level matrix, retry behavior, URL parameter gotcha, HMAC signature verification.
- `PSA-Pagination.md` — Navigable vs Forward-Only pagination, Link headers, while-loop pattern.
- `PSA-Service-Tickets.md` — Ticket field philosophy, recommended field mappings.
- `PSA-Versioning.md` — Pin API version via Accept header. Use `application/vnd.connectwise.com+json; version=2025.16`.
- `PSA-Cloud-URL-Formatting.md` — Dynamic base URL construction via `/login/companyinfo/{companyId}`.
- `Bundled-Requests.md` — Batch multiple API calls into one request via `/system/bundles`.
- `PSA-Markdown.md` — Ticket notes support markdown. Format session documentation output accordingly.
- `PSA-Company-Synchronization.md` — Filter companies by Status/Type for mapping UI.
- `PSA-Data-Protection.md` — Security role model, request minimal permissions (MY not ALL).
### Reference Files (read in this order)
1. `docs/connectwise/CONNECTWISE-API-REFERENCE.md` — Read FIRST. Quick reference covering auth patterns, tiered endpoint map, key field mappings, and integration architecture flows.
2. `docs/connectwise/connectwise-psa-resolutionflow-reference.json` — Extracted OpenAPI 3.0.1 spec (v2025.16) with only the 670 endpoints and 342 schemas relevant to ResolutionFlow. Use for exact field types, request/response shapes, and parameter details.
3. `docs/connectwise/connectwise-psa-openapi-full.json` — Complete ConnectWise PSA OpenAPI spec (1838 endpoints, 842 schemas). Only consult if you need an endpoint outside the extracted subset.
### Integration Architecture
- **Session → Ticket Notes:** Post auto-generated session documentation to ConnectWise tickets as internal analysis notes via `POST /service/tickets/{id}/notes`
- **Ticket Context → Session Runner:** Pull ticket details, company info, and attached configurations to give FlowPilot AI real-world context
- **Callbacks:** Register webhooks via `/system/callbacks` for real-time ticket event notifications to suggest relevant Flows
### Key Implementation Rules
- Auth: API Key auth (Base64 of `companyId+publicKey:privateKey`) + `clientId` header on every request
- All ConnectWise integration code belongs in a dedicated service layer (e.g., `services/connectwise/`) — do NOT scatter CW API calls across the codebase
- Each MSP tenant provides their own CW credentials — ResolutionFlow stores these per-team, never per-user
- Design for the Autotask integration following the same service layer pattern (future PSA)
- Respect CW API: cache board/status/priority lookups, paginate with max 1000 per page, handle retries gracefully
---
## Development Commands
```powershell
@@ -190,11 +237,13 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
```
### URLs
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API Docs: http://localhost:8000/api/docs
- Frontend: <http://localhost:5173>
- Backend API: <http://localhost:8000>
- API Docs: <http://localhost:8000/api/docs>
### Test Users (seeded via `scripts/seed_test_users.py`)
- All share password: `TestPass123!`
- `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com` (team_admin), `engineer@resolutionflow.example.com` (engineer), `pro@resolutionflow.example.com` (solo pro)
@@ -205,6 +254,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
### Top Gotchas (most commonly hit)
**1. DateTime Handling — Always timezone-aware:**
```python
# CORRECT
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
@@ -212,6 +262,7 @@ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezo
```
**2. SQLAlchemy Async — No lazy loading on new objects:**
```python
# WRONG — MissingGreenlet error
new_tree = Tree(...); db.add(new_tree); await db.flush()
@@ -222,6 +273,7 @@ await db.execute(tree_tag_assignments.insert().values(tree_id=new_tree.id, tag_i
```
**3. React State — Don't store object snapshots:**
```tsx
// WRONG — snapshot won't update
const [editingNode, setEditingNode] = useState(node)
@@ -231,17 +283,20 @@ const editingNode = editingNodeId ? findNode(editingNodeId, tree?.tree_structure
```
**4. Modal Draft State — Exclude store-managed fields:**
```tsx
const { children, ...draftWithoutChildren } = draft
updateNode(node.id, draftWithoutChildren) // Don't overwrite children
```
**5. Multiple FKs to same table — Specify `foreign_keys` on BOTH sides:**
```python
author = relationship("User", foreign_keys=[author_id], back_populates="trees")
```
**6. PostgreSQL NULL in UUID columns:**
```sql
SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid
```
@@ -255,6 +310,7 @@ SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid
**9. Public endpoints with optional auth:** Use manual `_get_optional_user(request, db)` helper, NOT `Optional[User]` param (FastAPI treats it as Pydantic field).
**10. React Router — Clear dirty state before navigation:**
```tsx
markSaved() // Clear isDirty BEFORE navigate()
navigate(`/trees/${newTree.id}/edit`)
@@ -352,6 +408,8 @@ navigate(`/trees/${newTree.id}/edit`)
**57. Node field priority for display/context:** Nodes use different label fields by type — procedural steps use `title`+`description`, decision nodes use `question`, action/solution nodes use `title`. When reading a node's label generically, check: `title``question``description``content``label`. See `copilot_service.py` `_build_flow_context()`.
**58. `scriptGeneratorStore.generate()` has an optional `sessionId` param:** `generate(sessionId?: string)` — do NOT pass it as a bare `onClick={generate}` handler (TypeScript error: MouseEvent not assignable to string). Always wrap: `onClick={() => generate()}`.
---
## RBAC & Permissions
@@ -408,19 +466,24 @@ navigate(`/trees/${newTree.id}/edit`)
## Coding Standards
### Python
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
### TypeScript
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
### Git
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
- Large features: commit per phase with `npm run build` validation
### After Completing Work
When a feature, fix, or significant piece of work is finished and merged/committed:
1. **Update `CURRENT-STATE.md`** — move completed items, update "In Progress" and "What's Next" sections
2. **Update `03-DEVELOPMENT-ROADMAP.md`** — check off completed work, update phase status
3. **Close related GitHub Issues** — use `gh issue close #N` for any issues resolved by the work
@@ -451,7 +514,7 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| What | Where |
|------|-------|
| API Docs | http://localhost:8000/api/docs |
| API Docs | <http://localhost:8000/api/docs> |
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
| GitHub Issues | `gh issue list --state open` |

View File

@@ -18,6 +18,7 @@ from app.models.survey_response import SurveyResponse
from app.models.survey_invite import SurveyInvite
from app.models.ai_suggestion import AISuggestion # noqa: F401
from app.models.kb_import import KBImport, KBImportNode # noqa: F401
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration # noqa: F401
from app.core.config import settings
# this is the Alembic Config object

View File

@@ -0,0 +1,690 @@
"""add script templates
Revision ID: 057
Revises: 056
Create Date: 2026-03-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB, ENUM as PG_ENUM
import uuid
import json
from datetime import datetime, timezone
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── Create enum ──────────────────────────────────────────────────────
op.execute("CREATE TYPE script_complexity AS ENUM ('beginner', 'intermediate', 'advanced')")
# ── script_categories ────────────────────────────────────────────────
op.create_table(
"script_categories",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("slug", sa.String(100), nullable=False, unique=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("icon", sa.String(50), nullable=True),
sa.Column("sort_order", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_script_categories_slug", "script_categories", ["slug"])
# ── script_templates ─────────────────────────────────────────────────
op.create_table(
"script_templates",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("category_id", UUID(as_uuid=True), sa.ForeignKey("script_categories.id", ondelete="RESTRICT"), nullable=False),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("slug", sa.String(200), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("use_case", sa.Text, nullable=True),
sa.Column("script_body", sa.Text, nullable=False),
sa.Column("parameters_schema", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("default_values", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("validation_rules", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("tags", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("complexity", PG_ENUM("beginner", "intermediate", "advanced", name="script_complexity", create_type=False), nullable=False, server_default=sa.text("'beginner'")),
sa.Column("estimated_runtime", sa.String(50), nullable=True),
sa.Column("requires_elevation", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("requires_modules", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("version", sa.Integer, nullable=False, server_default=sa.text("1")),
sa.Column("is_verified", sa.Boolean, nullable=False, server_default=sa.text("false")),
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
sa.Column("usage_count", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_script_templates_category_id", "script_templates", ["category_id"])
op.create_index("ix_script_templates_team_id", "script_templates", ["team_id"])
op.create_index("ix_script_templates_slug", "script_templates", ["slug"])
# ── script_generations ───────────────────────────────────────────────
op.create_table(
"script_generations",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("template_id", UUID(as_uuid=True), sa.ForeignKey("script_templates.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="SET NULL"), nullable=True),
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True),
sa.Column("parameters_used", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column("generated_script", sa.Text, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_script_generations_template_id", "script_generations", ["template_id"])
op.create_index("ix_script_generations_user_id", "script_generations", ["user_id"])
op.create_index("ix_script_generations_session_id", "script_generations", ["session_id"])
# ── Seed: Active Directory category ──────────────────────────────────
now = datetime.now(timezone.utc)
cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
conn = op.get_bind()
conn.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (:id, :name, :slug, :description, :icon, :sort_order, true, :now, :now)
"""),
{
"id": cat_id,
"name": "Active Directory",
"slug": "active-directory",
"description": "User account and group management scripts for Active Directory environments",
"icon": "shield-check",
"sort_order": 1,
"now": now,
}
)
templates = _get_seed_templates(cat_id, now)
for tmpl in templates:
conn.execute(
sa.text("""
INSERT INTO script_templates (
id, category_id, name, slug, description, use_case,
script_body, parameters_schema, default_values, validation_rules,
tags, complexity, estimated_runtime, requires_elevation,
requires_modules, version, is_verified, is_active, usage_count,
created_at, updated_at
) VALUES (
:id, :category_id, :name, :slug, :description, :use_case,
:script_body, CAST(:parameters_schema AS jsonb), CAST(:default_values AS jsonb), CAST(:validation_rules AS jsonb),
CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation,
CAST(:requires_modules AS jsonb), 1, true, true, 0,
:now, :now
)
"""),
tmpl,
)
def downgrade() -> None:
op.drop_table("script_generations")
op.drop_table("script_templates")
op.drop_table("script_categories")
op.execute("DROP TYPE IF EXISTS script_complexity")
def _get_seed_templates(cat_id: uuid.UUID, now: datetime) -> list[dict]:
"""Return seed data for the six AD User Management templates."""
def tmpl(
id_suffix: int,
name: str,
slug: str,
description: str,
use_case: str,
script_body: str,
parameters_schema: dict,
complexity: str,
estimated_runtime: str,
requires_elevation: bool,
tags: list[str],
) -> dict:
return {
"id": uuid.UUID(f"00000000-0000-0000-0001-{id_suffix:012d}"),
"category_id": cat_id,
"name": name,
"slug": slug,
"description": description,
"use_case": use_case,
"script_body": script_body,
"parameters_schema": json.dumps(parameters_schema),
"default_values": json.dumps({}),
"validation_rules": json.dumps({}),
"tags": json.dumps(tags),
"complexity": complexity,
"estimated_runtime": estimated_runtime,
"requires_elevation": requires_elevation,
"requires_modules": json.dumps([]),
"now": now,
}
return [
# ── 1: Create AD User ─────────────────────────────────────────────
tmpl(
id_suffix=1,
name="Create AD User Account",
slug="create-ad-user",
description="Creates a new Active Directory user account with full property configuration.",
use_case="Use when onboarding a new employee or creating a service account in Active Directory.",
complexity="intermediate",
estimated_runtime="< 5 seconds",
requires_elevation=True,
tags=["active-directory", "user-management", "onboarding"],
parameters_schema={
"parameters": [
{"key": "first_name", "label": "First Name", "type": "text", "required": True, "group": "User Identity", "order": 1, "validation": {"pattern": "^[a-zA-Z\\-']+$", "maxLength": 64}},
{"key": "last_name", "label": "Last Name", "type": "text", "required": True, "group": "User Identity", "order": 2, "validation": {"pattern": "^[a-zA-Z\\-']+$", "maxLength": 64}},
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "User Identity", "order": 3, "placeholder": "jsmith", "help_text": "The logon name (typically firstname.lastname or first initial + last name). Max 20 characters."},
{"key": "upn_suffix", "label": "UPN Suffix", "type": "text", "required": True, "group": "User Identity", "order": 4, "placeholder": "contoso.com"},
{"key": "ou_path", "label": "Organizational Unit", "type": "text", "required": True, "group": "AD Configuration", "order": 5, "placeholder": "OU=Users,DC=contoso,DC=com", "validation": {"pattern": "^(OU|CN)=.+"}},
{"key": "password", "label": "Initial Password", "type": "password", "required": True, "group": "Security", "order": 6, "sensitive": True},
{"key": "job_title", "label": "Job Title", "type": "text", "required": False, "group": "Profile", "order": 7},
{"key": "department", "label": "Department", "type": "text", "required": False, "group": "Profile", "order": 8},
{"key": "groups", "label": "Security Groups", "type": "multi_text", "required": False, "group": "Group Membership", "order": 9, "placeholder": "Domain Users"},
{"key": "force_password_change", "label": "Force Password Change at Logon", "type": "boolean", "required": False, "group": "Security", "order": 10, "default": True},
{"key": "account_enabled", "label": "Enable Account", "type": "boolean", "required": False, "group": "Security", "order": 11, "default": True},
]
},
script_body=r"""# ============================================================
# Create AD User Account
# Generated by ResolutionFlow Script Generator
# Template Version: 1
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$FirstName = '{{ first_name }}'
$LastName = '{{ last_name }}'
$SamAccountName = '{{ sam_account_name }}'
$UPNSuffix = '{{ upn_suffix }}'
$OUPath = '{{ ou_path }}'
$Password = {{ password | as_secure_string }}
$ForcePasswordChange = {{ force_password_change | as_bool }}
$AccountEnabled = {{ account_enabled | as_bool }}
{% if job_title %}
$JobTitle = '{{ job_title }}'
{% endif %}
{% if department %}
$Department = '{{ department }}'
{% endif %}
{% if groups %}
$Groups = @({{ groups | as_array }})
{% endif %}
$DisplayName = "$FirstName $LastName"
$UPN = "$SamAccountName@$UPNSuffix"
try {
Import-Module ActiveDirectory -ErrorAction Stop
Write-Host "[CHECK] ActiveDirectory module loaded" -ForegroundColor Green
} catch {
Write-Host "[ERROR] ActiveDirectory module not available: $_" -ForegroundColor Red
exit 1
}
try {
$userParams = @{
Name = $DisplayName
GivenName = $FirstName
Surname = $LastName
SamAccountName = $SamAccountName
UserPrincipalName = $UPN
Path = $OUPath
AccountPassword = $Password
Enabled = $AccountEnabled
ChangePasswordAtLogon = $ForcePasswordChange
}
{% if job_title %}
$userParams["Title"] = $JobTitle
{% endif %}
{% if department %}
$userParams["Department"] = $Department
{% endif %}
New-ADUser @userParams -ErrorAction Stop
Write-Host "[OK] User account created: $SamAccountName ($DisplayName)" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Failed to create user: $_" -ForegroundColor Red
exit 1
}
{% if groups %}
foreach ($group in $Groups) {
try {
Add-ADGroupMember -Identity $group -Members $SamAccountName -ErrorAction Stop
Write-Host "[OK] Added to group: $group" -ForegroundColor Green
} catch {
Write-Host "[WARN] Could not add to group '$group': $_" -ForegroundColor Yellow
}
}
{% endif %}
Write-Host ""
Write-Host "[DONE] User $SamAccountName created successfully." -ForegroundColor Cyan
Write-Host " UPN: $UPN" -ForegroundColor Cyan
""",
),
# ── 2: Disable AD User ────────────────────────────────────────────
tmpl(
id_suffix=2,
name="Disable AD User Account",
slug="disable-ad-user",
description="Disables an Active Directory user account with optional group removal and OU relocation.",
use_case="Use when offboarding an employee or temporarily suspending access.",
complexity="beginner",
estimated_runtime="< 5 seconds",
requires_elevation=True,
tags=["active-directory", "user-management", "offboarding"],
parameters_schema={
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1, "placeholder": "jsmith"},
{"key": "disable_reason", "label": "Reason", "type": "text", "required": False, "group": "Audit", "order": 2, "placeholder": "Employee offboarding"},
{"key": "remove_groups", "label": "Remove from All Groups", "type": "boolean", "required": False, "group": "Options", "order": 3, "default": False},
{"key": "move_to_disabled_ou", "label": "Move to Disabled OU", "type": "boolean", "required": False, "group": "Options", "order": 4, "default": False},
{"key": "disabled_ou_path", "label": "Disabled OU Path", "type": "text", "required": False, "group": "Options", "order": 5, "placeholder": "OU=Disabled,DC=contoso,DC=com"},
]
},
script_body=r"""# ============================================================
# Disable AD User Account
# Generated by ResolutionFlow Script Generator
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$SamAccountName = '{{ sam_account_name }}'
{% if disable_reason %}
$DisableReason = '{{ disable_reason }}'
{% endif %}
$RemoveGroups = {{ remove_groups | as_bool }}
$MoveToDisabled = {{ move_to_disabled_ou | as_bool }}
{% if disabled_ou_path %}
$DisabledOUPath = '{{ disabled_ou_path }}'
{% endif %}
try {
Import-Module ActiveDirectory -ErrorAction Stop
} catch {
Write-Host "[ERROR] ActiveDirectory module not available: $_" -ForegroundColor Red; exit 1
}
try {
$user = Get-ADUser -Identity $SamAccountName -Properties MemberOf -ErrorAction Stop
Write-Host "[CHECK] Found user: $($user.DistinguishedName)" -ForegroundColor Green
} catch {
Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1
}
try {
Disable-ADAccount -Identity $SamAccountName -ErrorAction Stop
Write-Host "[OK] Account disabled: $SamAccountName" -ForegroundColor Green
{% if disable_reason %}
Set-ADUser -Identity $SamAccountName -Description $DisableReason
Write-Host "[OK] Disable reason recorded in Description field" -ForegroundColor Green
{% endif %}
} catch {
Write-Host "[ERROR] Failed to disable account: $_" -ForegroundColor Red; exit 1
}
{% if remove_groups %}
if ($RemoveGroups) {
$groups = $user.MemberOf
foreach ($group in $groups) {
try {
Remove-ADGroupMember -Identity $group -Members $SamAccountName -Confirm:$false -ErrorAction Stop
Write-Host "[OK] Removed from group: $group" -ForegroundColor Green
} catch {
Write-Host "[WARN] Could not remove from group: $group" -ForegroundColor Yellow
}
}
}
{% endif %}
{% if move_to_disabled_ou %}
if ($MoveToDisabled) {
try {
Move-ADObject -Identity $user.DistinguishedName -TargetPath $DisabledOUPath -ErrorAction Stop
Write-Host "[OK] Moved to disabled OU: $DisabledOUPath" -ForegroundColor Green
} catch {
Write-Host "[WARN] Could not move to disabled OU: $_" -ForegroundColor Yellow
}
}
{% endif %}
Write-Host ""
Write-Host "[DONE] Account $SamAccountName has been disabled." -ForegroundColor Cyan
""",
),
# ── 3: Reset AD Password ──────────────────────────────────────────
tmpl(
id_suffix=3,
name="Reset AD Password",
slug="reset-ad-password",
description="Resets an Active Directory user password with optional account unlock.",
use_case="Use when a user has forgotten their password or their account is locked out.",
complexity="beginner",
estimated_runtime="< 5 seconds",
requires_elevation=True,
tags=["active-directory", "password", "account-management"],
parameters_schema={
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1},
{"key": "new_password", "label": "New Password", "type": "password", "required": True, "group": "Security", "order": 2, "sensitive": True},
{"key": "force_change_at_logon", "label": "Force Change at Next Logon", "type": "boolean", "required": False, "group": "Security", "order": 3, "default": True},
{"key": "unlock_account", "label": "Unlock Account if Locked", "type": "boolean", "required": False, "group": "Options", "order": 4, "default": True},
]
},
script_body=r"""# ============================================================
# Reset AD Password
# Generated by ResolutionFlow Script Generator
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$SamAccountName = '{{ sam_account_name }}'
$NewPassword = {{ new_password | as_secure_string }}
$ForceChange = {{ force_change_at_logon | as_bool }}
$UnlockAccount = {{ unlock_account | as_bool }}
try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 }
try {
Set-ADAccountPassword -Identity $SamAccountName -NewPassword $NewPassword -Reset -ErrorAction Stop
Write-Host "[OK] Password reset for: $SamAccountName" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Failed to reset password: $_" -ForegroundColor Red; exit 1
}
if ($ForceChange) {
Set-ADUser -Identity $SamAccountName -ChangePasswordAtLogon $true
Write-Host "[OK] User must change password at next logon" -ForegroundColor Green
}
if ($UnlockAccount) {
try {
Unlock-ADAccount -Identity $SamAccountName -ErrorAction Stop
Write-Host "[OK] Account unlocked" -ForegroundColor Green
} catch {
Write-Host "[WARN] Could not unlock account (may not have been locked): $_" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "[DONE] Password reset complete for $SamAccountName." -ForegroundColor Cyan
""",
),
# ── 4: Unlock AD Account ──────────────────────────────────────────
tmpl(
id_suffix=4,
name="Unlock AD Account",
slug="unlock-ad-account",
description="Unlocks a locked-out Active Directory user account and optionally shows lockout information.",
use_case="Use when a user is locked out after too many failed login attempts.",
complexity="beginner",
estimated_runtime="< 5 seconds",
requires_elevation=True,
tags=["active-directory", "account-management", "lockout"],
parameters_schema={
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1},
{"key": "show_lockout_info", "label": "Show Lockout Source Info", "type": "boolean", "required": False, "group": "Options", "order": 2, "default": False, "help_text": "Queries domain controllers for lockout source (may be slow)"},
]
},
script_body=r"""# ============================================================
# Unlock AD Account
# Generated by ResolutionFlow Script Generator
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$SamAccountName = '{{ sam_account_name }}'
$ShowLockoutInfo = {{ show_lockout_info | as_bool }}
try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 }
try {
$user = Get-ADUser -Identity $SamAccountName -Properties LockedOut, BadLogonCount, LastBadPasswordAttempt -ErrorAction Stop
Write-Host "[CHECK] User found: $($user.DistinguishedName)" -ForegroundColor Green
Write-Host " Locked Out: $($user.LockedOut)" -ForegroundColor Cyan
Write-Host " Bad Logon Count: $($user.BadLogonCount)" -ForegroundColor Cyan
Write-Host " Last Bad Password Attempt: $($user.LastBadPasswordAttempt)" -ForegroundColor Cyan
} catch {
Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1
}
if (-not $user.LockedOut) {
Write-Host "[INFO] Account is not currently locked." -ForegroundColor Yellow
} else {
try {
Unlock-ADAccount -Identity $SamAccountName -ErrorAction Stop
Write-Host "[OK] Account unlocked: $SamAccountName" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Failed to unlock account: $_" -ForegroundColor Red; exit 1
}
}
{% if show_lockout_info %}
if ($ShowLockoutInfo) {
Write-Host ""
Write-Host "[INFO] Querying domain controllers for lockout source..." -ForegroundColor Cyan
try {
$DCs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
foreach ($DC in $DCs) {
$events = Get-WinEvent -ComputerName $DC -FilterHashtable @{
LogName = "Security"
Id = 4740
} -MaxEvents 10 -ErrorAction SilentlyContinue |
Where-Object { $_.Properties[0].Value -eq $SamAccountName }
foreach ($e in $events) {
Write-Host " DC: $DC | Source: $($e.Properties[1].Value) | Time: $($e.TimeCreated)" -ForegroundColor Yellow
}
}
} catch {
Write-Host "[WARN] Could not query lockout source: $_" -ForegroundColor Yellow
}
}
{% endif %}
Write-Host ""
Write-Host "[DONE] Unlock operation complete for $SamAccountName." -ForegroundColor Cyan
""",
),
# ── 5: Delete AD User ─────────────────────────────────────────────
tmpl(
id_suffix=5,
name="Delete AD User Account",
slug="delete-ad-user",
description="Permanently deletes an Active Directory user account with optional CSV backup of user properties.",
use_case="Use when permanently removing an account that is no longer needed. Prefer Disable for departing employees.",
complexity="advanced",
estimated_runtime="< 10 seconds",
requires_elevation=True,
tags=["active-directory", "user-management", "destructive"],
parameters_schema={
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "group": "Target Account", "order": 1},
{"key": "confirm_deletion", "label": "I confirm this account should be permanently deleted", "type": "boolean", "required": True, "group": "Confirmation", "order": 2, "default": False},
{"key": "backup_to_csv", "label": "Back Up User Properties to CSV First", "type": "boolean", "required": False, "group": "Options", "order": 3, "default": True},
{"key": "backup_path", "label": "CSV Backup Path", "type": "text", "required": False, "group": "Options", "order": 4, "placeholder": "C:\\Backups\\ad-user-backup.csv"},
]
},
script_body=r"""# ============================================================
# Delete AD User Account
# Generated by ResolutionFlow Script Generator
# DESTRUCTIVE OPERATION
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$SamAccountName = '{{ sam_account_name }}'
$ConfirmDeletion = {{ confirm_deletion | as_bool }}
$BackupToCSV = {{ backup_to_csv | as_bool }}
{% if backup_path %}
$BackupPath = '{{ backup_path }}'
{% else %}
$BackupPath = "C:\Temp\ad-user-backup-$SamAccountName-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
{% endif %}
try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Host "[ERROR] $_ " -ForegroundColor Red; exit 1 }
if (-not $ConfirmDeletion) {
Write-Host "[ABORT] Deletion not confirmed. Set confirm_deletion to true to proceed." -ForegroundColor Red
exit 1
}
try {
$user = Get-ADUser -Identity $SamAccountName -Properties * -ErrorAction Stop
Write-Host "[CHECK] Found user: $($user.DistinguishedName)" -ForegroundColor Yellow
Write-Host "[WARN] About to permanently delete this account." -ForegroundColor Yellow
} catch {
Write-Host "[ERROR] User not found: $SamAccountName" -ForegroundColor Red; exit 1
}
if ($BackupToCSV) {
try {
$user | Select-Object Name, SamAccountName, UserPrincipalName, DistinguishedName, EmailAddress, Title, Department, Enabled, Created, Modified |
Export-Csv -Path $BackupPath -NoTypeInformation -ErrorAction Stop
Write-Host "[OK] User properties backed up to: $BackupPath" -ForegroundColor Green
} catch {
Write-Host "[WARN] Could not write backup: $_" -ForegroundColor Yellow
}
}
try {
Remove-ADUser -Identity $SamAccountName -Confirm:$false -ErrorAction Stop
Write-Host "[OK] User account permanently deleted: $SamAccountName" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Failed to delete user: $_" -ForegroundColor Red; exit 1
}
Write-Host ""
Write-Host "[DONE] Account $SamAccountName has been permanently deleted." -ForegroundColor Cyan
""",
),
# ── 6: Bulk User Import ───────────────────────────────────────────
tmpl(
id_suffix=6,
name="Bulk User Import from CSV",
slug="bulk-user-import",
description="Imports multiple Active Directory user accounts from a CSV file with dry-run support and detailed logging.",
use_case="Use when onboarding multiple employees at once from an HR system export.",
complexity="advanced",
estimated_runtime="1-2 minutes",
requires_elevation=True,
tags=["active-directory", "bulk", "onboarding", "csv"],
parameters_schema={
"parameters": [
{"key": "csv_path", "label": "CSV File Path", "type": "text", "required": True, "group": "Source", "order": 1, "placeholder": "C:\\Imports\\new-users.csv", "help_text": "CSV must have columns: FirstName, LastName, SamAccountName, UPN, Department, JobTitle"},
{"key": "ou_path", "label": "Target Organizational Unit", "type": "text", "required": True, "group": "AD Configuration", "order": 2, "placeholder": "OU=Users,DC=contoso,DC=com"},
{"key": "default_password", "label": "Default Password", "type": "password", "required": True, "group": "Security", "order": 3, "sensitive": True},
{"key": "groups", "label": "Add All Users to Groups", "type": "multi_text", "required": False, "group": "Group Membership", "order": 4},
{"key": "force_password_change", "label": "Force Password Change at Logon", "type": "boolean", "required": False, "group": "Security", "order": 5, "default": True},
{"key": "log_path", "label": "Log File Path", "type": "text", "required": False, "group": "Options", "order": 6, "placeholder": "C:\\Logs\\bulk-import.log"},
{"key": "dry_run", "label": "Dry Run (Preview Only - No Changes Made)", "type": "boolean", "required": False, "group": "Options", "order": 7, "default": False},
]
},
script_body=r"""# ============================================================
# Bulk User Import from CSV
# Generated by ResolutionFlow Script Generator
# ============================================================
# CSV Format: FirstName, LastName, SamAccountName, UPN, Department, JobTitle
# ============================================================
#Requires -Modules ActiveDirectory
#Requires -RunAsAdministrator
$CSVPath = '{{ csv_path }}'
$OUPath = '{{ ou_path }}'
$DefaultPassword = {{ default_password | as_secure_string }}
$ForceChange = {{ force_password_change | as_bool }}
$DryRun = {{ dry_run | as_bool }}
{% if log_path %}
$LogPath = '{{ log_path }}'
{% else %}
$LogPath = "C:\Temp\bulk-import-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
{% endif %}
{% if groups %}
$DefaultGroups = @({{ groups | as_array }})
{% endif %}
function Write-Log {
param($Message, $Level = "INFO")
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
$color = switch ($Level) { "ERROR" { "Red" } "WARN" { "Yellow" } "OK" { "Green" } default { "White" } }
Write-Host $line -ForegroundColor $color
}
try { Import-Module ActiveDirectory -ErrorAction Stop } catch { Write-Log "ActiveDirectory module not available: $_" "ERROR"; exit 1 }
if (-not (Test-Path $CSVPath)) { Write-Log "CSV file not found: $CSVPath" "ERROR"; exit 1 }
$users = Import-Csv -Path $CSVPath
Write-Log "Loaded $($users.Count) users from CSV"
if ($DryRun) { Write-Log "DRY RUN MODE - no changes will be made" "WARN" }
$successCount = 0
$errorCount = 0
foreach ($row in $users) {
$sam = $row.SamAccountName
try {
$existing = Get-ADUser -Filter "SamAccountName -eq '$sam'" -ErrorAction SilentlyContinue
if ($existing) { Write-Log "SKIP: $sam already exists" "WARN"; continue }
if (-not $DryRun) {
New-ADUser `
-Name "$($row.FirstName) $($row.LastName)" `
-GivenName $row.FirstName `
-Surname $row.LastName `
-SamAccountName $sam `
-UserPrincipalName $row.UPN `
-Path $OUPath `
-AccountPassword $DefaultPassword `
-Enabled $true `
-ChangePasswordAtLogon $ForceChange `
-Title $row.JobTitle `
-Department $row.Department `
-ErrorAction Stop
{% if groups %}
foreach ($group in $DefaultGroups) {
try { Add-ADGroupMember -Identity $group -Members $sam -ErrorAction Stop }
catch { Write-Log "WARN: $sam - could not add to group '$group'" "WARN" }
}
{% endif %}
}
Write-Log "$(if ($DryRun) { 'PREVIEW' } else { 'CREATED' }): $sam ($($row.FirstName) $($row.LastName))" "OK"
$successCount++
} catch {
Write-Log "FAILED: $sam - $_" "ERROR"
$errorCount++
}
}
Write-Log ""
Write-Log "[DONE] Import complete. Success: $successCount | Errors: $errorCount | Log: $LogPath" "INFO"
""",
),
]

View File

@@ -0,0 +1,413 @@
"""Script Generator API endpoints."""
from typing import Annotated, Optional
from uuid import UUID
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.core.permissions import can_manage_script_template, can_create_content
from app.models.user import User
from app.models.script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
from app.schemas.script_template import (
ScriptCategoryResponse,
ScriptTemplateCreate,
ScriptTemplateUpdate,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptGenerateRequest,
ScriptGenerateResponse,
ScriptGenerationRecord,
)
from app.services.script_template_engine import ScriptTemplateEngine, ScriptRenderError
router = APIRouter(prefix="/scripts", tags=["scripts"])
_engine = ScriptTemplateEngine()
# ── Categories ────────────────────────────────────────────────────────────
@router.get("/categories", response_model=list[ScriptCategoryResponse])
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[ScriptCategoryResponse]:
result = await db.execute(
select(ScriptCategory)
.where(ScriptCategory.is_active == True) # noqa: E712
.order_by(ScriptCategory.sort_order)
)
categories = result.scalars().all()
count_result = await db.execute(
select(ScriptTemplate.category_id, func.count(ScriptTemplate.id))
.where(ScriptTemplate.is_active == True) # noqa: E712
.group_by(ScriptTemplate.category_id)
)
counts = dict(count_result.all())
return [
ScriptCategoryResponse(
id=cat.id,
name=cat.name,
slug=cat.slug,
description=cat.description,
icon=cat.icon,
sort_order=cat.sort_order,
template_count=counts.get(cat.id, 0),
)
for cat in categories
]
# ── Templates ─────────────────────────────────────────────────────────────
@router.get("/templates", response_model=list[ScriptTemplateListItem])
async def list_templates(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
category_slug: Optional[str] = Query(None),
search: Optional[str] = Query(None),
tags: Optional[str] = Query(None, description="Comma-separated tags"),
managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"),
) -> list[ScriptTemplateListItem]:
query = (
select(ScriptTemplate)
.join(ScriptCategory, ScriptTemplate.category_id == ScriptCategory.id)
.where(ScriptTemplate.is_active == True) # noqa: E712
.where(
or_(
ScriptTemplate.team_id == None, # noqa: E711
ScriptTemplate.team_id == current_user.team_id,
)
)
)
if category_slug:
query = query.where(ScriptCategory.slug == category_slug)
if search:
term = f"%{search.lower()}%"
query = query.where(
or_(
func.lower(ScriptTemplate.name).like(term),
func.lower(ScriptTemplate.description).like(term),
func.lower(ScriptTemplate.slug).like(term),
)
)
if managed:
if current_user.is_super_admin:
pass # super admin can edit all
elif current_user.account_role == "owner":
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)
result = await db.execute(query.order_by(ScriptTemplate.name))
templates = result.scalars().all()
if tags:
tag_list = [t.strip().lower() for t in tags.split(",")]
templates = [
t
for t in templates
if any(tag in [tg.lower() for tg in (t.tags or [])] for tag in tag_list)
]
return [ScriptTemplateListItem.model_validate(t) for t in templates]
@router.get("/templates/{template_id}", response_model=ScriptTemplateDetail)
async def get_template(
template_id: UUID,
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
or_(
ScriptTemplate.team_id == None, # noqa: E711
ScriptTemplate.team_id == current_user.team_id,
),
)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Template not found"
)
return ScriptTemplateDetail.model_validate(template)
@router.post(
"/templates",
response_model=ScriptTemplateDetail,
status_code=status.HTTP_201_CREATED,
)
async def create_template(
data: ScriptTemplateCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptTemplateDetail:
if not can_create_content(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Engineer access required to create templates",
)
cat_result = await db.execute(
select(ScriptCategory).where(
ScriptCategory.id == data.category_id,
ScriptCategory.is_active == True, # noqa: E712
)
)
if not cat_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Category not found"
)
slug = re.sub(r"[^a-z0-9]+", "-", data.name.lower()).strip("-")
template = ScriptTemplate(
category_id=data.category_id,
team_id=current_user.team_id,
created_by=current_user.id,
name=data.name,
slug=slug,
description=data.description,
use_case=data.use_case,
script_body=data.script_body,
parameters_schema=data.parameters_schema,
default_values=data.default_values,
validation_rules=data.validation_rules,
tags=data.tags,
complexity=data.complexity,
estimated_runtime=data.estimated_runtime,
requires_elevation=data.requires_elevation,
requires_modules=data.requires_modules,
)
db.add(template)
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)
@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)
@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()
@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)
# ── Generate ──────────────────────────────────────────────────────────────
@router.post("/generate", response_model=ScriptGenerateResponse)
async def generate_script(
data: ScriptGenerateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptGenerateResponse:
result = await db.execute(
select(ScriptTemplate).where(
ScriptTemplate.id == data.template_id,
ScriptTemplate.is_active == True, # noqa: E712
or_(
ScriptTemplate.team_id == None, # noqa: E711
ScriptTemplate.team_id == current_user.team_id,
),
)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Template not found"
)
try:
rendered_script = _engine.render(template.script_body, data.parameters)
except ScriptRenderError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
)
params_schema = template.parameters_schema or {}
sensitive_keys = {
p["key"]
for p in params_schema.get("parameters", [])
if p.get("sensitive", False)
}
redacted_params = _engine.redact_sensitive(data.parameters, sensitive_keys)
generation = ScriptGeneration(
template_id=template.id,
user_id=current_user.id,
team_id=current_user.team_id,
session_id=data.session_id,
parameters_used=redacted_params,
generated_script=rendered_script,
)
db.add(generation)
template.usage_count += 1
await db.commit()
await db.refresh(generation)
warnings: list[str] = []
if template.requires_elevation:
warnings.append("This script requires 'Run as Administrator'")
return ScriptGenerateResponse(
id=generation.id,
script=rendered_script,
warnings=warnings,
metadata={
"template_name": template.name,
"template_version": template.version,
"requires_elevation": template.requires_elevation,
"requires_modules": template.requires_modules,
"generated_at": generation.created_at.isoformat(),
"estimated_runtime": template.estimated_runtime,
},
)
# ── Generations history ───────────────────────────────────────────────────
@router.get("/generations", response_model=list[ScriptGenerationRecord])
async def list_generations(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[ScriptGenerationRecord]:
result = await db.execute(
select(ScriptGeneration, ScriptTemplate.name)
.join(ScriptTemplate, ScriptGeneration.template_id == ScriptTemplate.id)
.where(ScriptGeneration.user_id == current_user.id)
.order_by(ScriptGeneration.created_at.desc())
.limit(limit)
.offset(offset)
)
rows = result.all()
return [
ScriptGenerationRecord(
id=gen.id,
template_id=gen.template_id,
template_name=name,
parameters_used=gen.parameters_used,
created_at=gen.created_at,
)
for gen, name in rows
]

View File

@@ -16,6 +16,7 @@ from app.api.endpoints import tree_transfer
from app.api.endpoints import ai_suggestions
from app.api.endpoints import kb_accelerator
from app.api.endpoints import beta_signup
from app.api.endpoints import scripts
api_router = APIRouter()
@@ -56,3 +57,4 @@ api_router.include_router(tree_transfer.router)
api_router.include_router(ai_suggestions.router)
api_router.include_router(kb_accelerator.router)
api_router.include_router(beta_signup.router)
api_router.include_router(scripts.router)

View File

@@ -169,3 +169,19 @@ def can_create_step_category(user: User, account_id: Optional[UUID]) -> bool:
if user.account_role == "owner" and account_id == user.account_id and user.account_id is not None:
return True
return False
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

View File

@@ -35,6 +35,7 @@ from .assistant_chat import AssistantChat
from .survey_response import SurveyResponse
from .survey_invite import SurveyInvite
from .kb_import import KBImport, KBImportNode
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
__all__ = [
"User",
@@ -82,4 +83,7 @@ __all__ = [
"SurveyInvite",
"KBImport",
"KBImportNode",
"ScriptCategory",
"ScriptTemplate",
"ScriptGeneration",
]

View File

@@ -0,0 +1,107 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.team import Team
from app.models.session import Session
class ScriptCategory(Base):
__tablename__ = "script_categories"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
icon: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
templates: Mapped[list["ScriptTemplate"]] = relationship("ScriptTemplate", back_populates="category")
class ScriptTemplate(Base):
__tablename__ = "script_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
category_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("script_categories.id", ondelete="RESTRICT"), nullable=False, index=True
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, index=True
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
script_body: Mapped[str] = mapped_column(Text, nullable=False)
parameters_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
default_values: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
validation_rules: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
complexity: Mapped[str] = mapped_column(
SAEnum("beginner", "intermediate", "advanced", name="script_complexity"), nullable=False, default="beginner"
)
estimated_runtime: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
requires_elevation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
requires_modules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
usage_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
category: Mapped["ScriptCategory"] = relationship("ScriptCategory", back_populates="templates")
team: Mapped[Optional["Team"]] = relationship("Team")
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
generations: Mapped[list["ScriptGeneration"]] = relationship("ScriptGeneration", back_populates="template")
class ScriptGeneration(Base):
__tablename__ = "script_generations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("script_templates.id", ondelete="RESTRICT"), nullable=False, index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("teams.id", ondelete="SET NULL"), nullable=True, index=True
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
parameters_used: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
generated_script: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
template: Mapped["ScriptTemplate"] = relationship("ScriptTemplate", back_populates="generations")
user: Mapped["User"] = relationship("User")

View File

@@ -10,6 +10,11 @@ from .ai_builder import (
AIStartResponse, AIScaffoldResponse, AIBranchDetailResponse, AIAssembleResponse,
AIQuotaStatusResponse,
)
from .script_template import (
ScriptCategoryResponse,
ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail,
ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord,
)
__all__ = [
# User
@@ -30,4 +35,8 @@ __all__ = [
"AIStartRequest", "AIScaffoldRequest", "AIBranchDetailRequest", "AIAssembleRequest",
"AIStartResponse", "AIScaffoldResponse", "AIBranchDetailResponse", "AIAssembleResponse",
"AIQuotaStatusResponse",
# Script Generator
"ScriptCategoryResponse",
"ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail",
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
]

View File

@@ -0,0 +1,138 @@
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, Field
# ── Parameter schema types ──────────────────────────────────────────────────
class ScriptParameterValidation(BaseModel):
pattern: Optional[str] = None
min_length: Optional[int] = None
max_length: Optional[int] = None
min_value: Optional[float] = None
max_value: Optional[float] = None
class ScriptParameter(BaseModel):
key: str
label: str
type: str # text | password | select | boolean | multi_text | number | textarea
required: bool = True
placeholder: Optional[str] = None
group: Optional[str] = None
order: int = 0
help_text: Optional[str] = None
options: Optional[list[dict]] = None # for select type: [{value, label}]
default: Optional[Any] = None
validation: Optional[ScriptParameterValidation] = None
sensitive: bool = False # password fields → redacted in generation record
class ScriptParametersSchema(BaseModel):
parameters: list[ScriptParameter]
# ── Category ────────────────────────────────────────────────────────────────
class ScriptCategoryResponse(BaseModel):
id: UUID
name: str
slug: str
description: Optional[str] = None
icon: Optional[str] = None
sort_order: int
template_count: int = 0
class Config:
from_attributes = True
# ── Template ────────────────────────────────────────────────────────────────
class ScriptTemplateCreate(BaseModel):
category_id: UUID
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
use_case: Optional[str] = None
script_body: str = Field(..., min_length=1)
parameters_schema: dict = Field(default_factory=dict)
default_values: dict = Field(default_factory=dict)
validation_rules: dict = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
complexity: str = Field(default="beginner", pattern="^(beginner|intermediate|advanced)$")
estimated_runtime: Optional[str] = None
requires_elevation: bool = False
requires_modules: list[str] = Field(default_factory=list)
class ScriptTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
use_case: Optional[str] = None
script_body: Optional[str] = None
parameters_schema: Optional[dict] = None
default_values: Optional[dict] = None
validation_rules: Optional[dict] = None
tags: Optional[list[str]] = None
complexity: Optional[str] = Field(None, pattern="^(beginner|intermediate|advanced)$")
estimated_runtime: Optional[str] = None
requires_elevation: Optional[bool] = None
requires_modules: Optional[list[str]] = None
is_active: Optional[bool] = None
class ScriptTemplateListItem(BaseModel):
id: UUID
category_id: UUID
team_id: Optional[UUID] = None
created_by: Optional[UUID] = None
name: str
slug: str
description: Optional[str] = None
tags: list[str]
complexity: str
estimated_runtime: Optional[str] = None
requires_elevation: bool
requires_modules: list[str]
is_verified: bool
usage_count: int
class Config:
from_attributes = True
class ScriptTemplateDetail(ScriptTemplateListItem):
use_case: Optional[str] = None
script_body: str
parameters_schema: dict
default_values: dict
validation_rules: dict
version: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ── Generation ───────────────────────────────────────────────────────────────
class ScriptGenerateRequest(BaseModel):
template_id: UUID
parameters: dict[str, Any]
session_id: Optional[UUID] = None
class ScriptGenerateResponse(BaseModel):
id: UUID
script: str
warnings: list[str] = Field(default_factory=list)
metadata: dict
class Config:
from_attributes = True
class ScriptGenerationRecord(BaseModel):
id: UUID
template_id: UUID
template_name: str
parameters_used: dict # passwords already redacted
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,139 @@
"""
ScriptTemplateEngine — renders parameterized PowerShell templates.
Template syntax:
{{ param_name }} — simple substitution (string-escaped)
{{ param | filter }} — substitution with filter applied
{% if param %} ... {% endif %} — conditional block
Security: all string values are PowerShell-escaped before insertion.
Sensitive parameter values are never stored in generation records — use redact_sensitive().
"""
import re
from typing import Any
class ScriptRenderError(Exception):
"""Raised when a required parameter is missing or rendering fails."""
class ScriptTemplateEngine:
# ── Public API ────────────────────────────────────────────────────────
def render(self, body: str, parameters: dict[str, Any]) -> str:
"""Render a template body with the given parameters. Raises ScriptRenderError on missing required params."""
result = self._process_conditionals(body, parameters)
result = self._substitute_params(result, parameters)
return result
def redact_sensitive(self, parameters: dict[str, Any], sensitive_keys: set[str]) -> dict[str, Any]:
"""Return a copy of parameters with sensitive values replaced by [REDACTED]."""
return {
k: "[REDACTED]" if k in sensitive_keys else v
for k, v in parameters.items()
}
# ── Conditional processing ────────────────────────────────────────────
def _process_conditionals(self, body: str, parameters: dict[str, Any]) -> str:
"""Process {% if param %} ... {% endif %} blocks."""
pattern = re.compile(
r'\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}',
re.DOTALL
)
def replace_block(match: re.Match) -> str:
key = match.group(1)
content = match.group(2)
value = parameters.get(key)
# Truthy: non-empty string, non-empty list, True, non-zero number
if value is None or value == "" or value == [] or value is False:
return ""
return content
return pattern.sub(replace_block, body)
# ── Parameter substitution ────────────────────────────────────────────
# Matches {{ param }} or {{ param | filter }}
_PLACEHOLDER = re.compile(r'\{\{\s*(\w+)(?:\s*\|\s*(\w+))?\s*\}\}')
def _substitute_params(self, body: str, parameters: dict[str, Any]) -> str:
missing: list[str] = []
def replace(match: re.Match) -> str:
key = match.group(1)
filter_name = match.group(2)
if key not in parameters:
missing.append(key)
return match.group(0) # leave as-is, report at end
value = parameters[key]
if filter_name:
return self._apply_filter(filter_name, value)
return self._escape_string(value)
result = self._PLACEHOLDER.sub(replace, body)
if missing:
raise ScriptRenderError(
f"Missing required template parameter(s): {', '.join(missing)}"
)
return result
# ── Filters ──────────────────────────────────────────────────────────
def _apply_filter(self, filter_name: str, value: Any) -> str:
filters = {
"as_secure_string": self._filter_as_secure_string,
"as_array": self._filter_as_array,
"as_bool": self._filter_as_bool,
"escape_single": self._filter_escape_single,
}
if filter_name not in filters:
raise ScriptRenderError(f"Unknown template filter: {filter_name}")
return filters[filter_name](value)
def _filter_as_secure_string(self, value: Any) -> str:
escaped = str(value).replace("'", "''")
return f"(ConvertTo-SecureString '{escaped}' -AsPlainText -Force)"
def _filter_as_array(self, value: Any) -> str:
if not isinstance(value, list):
value = [value]
items = ",".join(f"'{self._escape_single_quote(str(v))}'" for v in value)
return items
def _filter_as_bool(self, value: Any) -> str:
if isinstance(value, bool):
return "$true" if value else "$false"
return "$true" if str(value).lower() in ("true", "1", "yes") else "$false"
def _filter_escape_single(self, value: Any) -> str:
return self._escape_single_quote(str(value))
# ── Escaping helpers ──────────────────────────────────────────────────
def _escape_string(self, value: Any) -> str:
"""Escape a value for safe insertion into a PowerShell single-quoted context."""
if isinstance(value, bool):
return "$true" if value else "$false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
# Lists rendered without filter — join with space (caller should use | as_array for proper PS arrays)
return " ".join(str(v) for v in value)
s = str(value)
# Escape backtick (PS escape char) first, then dollar (variable interpolation)
s = s.replace("`", "``")
s = s.replace("$", "`$")
# Escape single quotes (doubling for PS single-quoted strings)
s = self._escape_single_quote(s)
return s
def _escape_single_quote(self, s: str) -> str:
return s.replace("'", "''")

View File

@@ -20,7 +20,13 @@ from app.core.config import settings
settings.REQUIRE_INVITE_CODE = False
# Test database URL (separate from production)
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test"
# Use DATABASE_TEST_URL env var if set (e.g. inside Docker where host is 'db'),
# otherwise fall back to localhost for local development.
import os
TEST_DATABASE_URL = os.environ.get(
"DATABASE_TEST_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test",
)
@pytest.fixture(scope="session")

View File

@@ -0,0 +1,114 @@
"""Tests for ScriptTemplateEngine — parameter substitution, sanitization, and filters."""
import pytest
from app.services.script_template_engine import ScriptTemplateEngine, ScriptRenderError
@pytest.fixture
def engine():
return ScriptTemplateEngine()
# ── Basic substitution ────────────────────────────────────────────────────
def test_simple_substitution(engine):
body = "New-ADUser -Name '{{ first_name }} {{ last_name }}'"
result = engine.render(body, {"first_name": "John", "last_name": "Smith"})
assert result == "New-ADUser -Name 'John Smith'"
def test_missing_required_param_raises(engine):
body = "New-ADUser -Name '{{ first_name }}'"
with pytest.raises(ScriptRenderError, match="first_name"):
engine.render(body, {})
def test_extra_params_ignored(engine):
body = "New-ADUser -Name '{{ first_name }}'"
result = engine.render(body, {"first_name": "John", "extra": "ignored"})
assert result == "New-ADUser -Name 'John'"
# ── Security: single-quote injection ─────────────────────────────────────
def test_single_quote_in_value_is_escaped(engine):
body = "Set-ADUser -Name '{{ name }}'"
result = engine.render(body, {"name": "O'Brien"})
# Single quotes doubled for PowerShell safety
assert "O''Brien" in result
def test_backtick_in_value_is_escaped(engine):
body = "Write-Host '{{ msg }}'"
result = engine.render(body, {"msg": "hello`world"})
assert "`" not in result or "``" in result # backtick is escaped
def test_dollar_sign_in_value_is_escaped(engine):
body = "Write-Host '{{ msg }}'"
result = engine.render(body, {"msg": "price is $100"})
# Dollar sign escaped so it doesn't interpolate as a PowerShell variable
assert "`$100" in result or "'price is $100'" in result
# ── Filters ──────────────────────────────────────────────────────────────
def test_as_secure_string_filter(engine):
body = "$secPwd = {{ password | as_secure_string }}"
result = engine.render(body, {"password": "MyP@ss123"})
assert "ConvertTo-SecureString" in result
assert "MyP@ss123" in result
assert "-AsPlainText -Force" in result
def test_as_array_filter(engine):
body = "$groups = @({{ groups | as_array }})"
result = engine.render(body, {"groups": ["GroupA", "GroupB"]})
assert "'GroupA','GroupB'" in result
def test_as_array_filter_single_item(engine):
body = "$groups = @({{ groups | as_array }})"
result = engine.render(body, {"groups": ["OnlyGroup"]})
assert "'OnlyGroup'" in result
def test_as_bool_filter_true(engine):
body = "$force = {{ force_change | as_bool }}"
result = engine.render(body, {"force_change": True})
assert "$true" in result
def test_as_bool_filter_false(engine):
body = "$force = {{ force_change | as_bool }}"
result = engine.render(body, {"force_change": False})
assert "$false" in result
# ── Conditional blocks ───────────────────────────────────────────────────
def test_if_block_included_when_truthy(engine):
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
result = engine.render(body, {"groups": ["GroupA"]})
assert "Add-Groups" in result
def test_if_block_excluded_when_falsy(engine):
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
result = engine.render(body, {"groups": []})
assert "Add-Groups" not in result
def test_if_block_excluded_when_missing(engine):
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
result = engine.render(body, {})
assert "Add-Groups" not in result
# ── Parameter redaction ──────────────────────────────────────────────────
def test_sensitive_params_redacted_in_record(engine):
params = {"first_name": "John", "password": "Secret123"}
sensitive_keys = {"password"}
redacted = engine.redact_sensitive(params, sensitive_keys)
assert redacted["first_name"] == "John"
assert redacted["password"] == "[REDACTED]"

View File

@@ -0,0 +1,238 @@
"""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):
_, 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)
# Registration auto-sets account_role="owner", so this user is already an owner
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"]
# Share — owner should be allowed
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=headers,
)
assert share_resp.status_code == 200
@pytest.mark.asyncio
async def test_engineer_cannot_share_template(self, client, test_db):
cat = await _create_category(test_db)
# Create a user and downgrade to engineer (registration sets owner by default)
user_data, token = await _register_and_login(client, "eng_noshare@example.com", "TestPass123!", "Eng NoShare")
result = await test_db.execute(select(User).where(User.email == "eng_noshare@example.com"))
user = result.scalar_one()
user.account_role = "engineer"
await test_db.commit()
# Re-login to get fresh token with updated role
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_noshare@example.com", "password": "TestPass123!"})
eng_token = login_resp.json()["access_token"]
eng_headers = {"Authorization": f"Bearer {eng_token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=eng_headers)
template_id = create_resp.json()["id"]
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=eng_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)
# Registration auto-sets account_role="owner"
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"]
# Share then unshare
await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=headers)
unshare_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=false",
headers=headers,
)
assert unshare_resp.status_code == 200
assert unshare_resp.json()["team_id"] is None

View File

@@ -0,0 +1,336 @@
"""Integration tests for Script Generator API endpoints."""
import json
import uuid
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
async def seed_script_data(test_db):
"""Seed script categories and templates into the test database."""
now = datetime.now(timezone.utc)
cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
# Insert category
await test_db.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (:id, :name, :slug, :description, :icon, :sort_order, true, :now, :now)
"""),
{
"id": cat_id,
"name": "Active Directory",
"slug": "active-directory",
"description": "User account and group management scripts",
"icon": "shield-check",
"sort_order": 1,
"now": now,
},
)
# Minimal template data for testing
templates = [
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000001"),
"slug": "create-ad-user",
"name": "Create AD User Account",
"description": "Creates a new Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
]
}),
"complexity": "intermediate",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "user-management"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000002"),
"slug": "disable-ad-user",
"name": "Disable AD User Account",
"description": "Disables an Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "offboarding"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000003"),
"slug": "reset-ad-password",
"name": "Reset AD Password",
"description": "Resets an Active Directory user password.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$NewPassword = {{ new_password | as_secure_string }}\n$ForceChange = {{ force_change_at_logon | as_bool }}\n$UnlockAccount = {{ unlock_account | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "new_password", "label": "New Password", "type": "password", "required": True, "order": 2, "sensitive": True},
{"key": "force_change_at_logon", "label": "Force Change at Next Logon", "type": "boolean", "required": True, "order": 3},
{"key": "unlock_account", "label": "Unlock Account if Locked", "type": "boolean", "required": True, "order": 4},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "password"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000004"),
"slug": "unlock-ad-account",
"name": "Unlock AD Account",
"description": "Unlocks a locked-out Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ShowLockoutInfo = {{ show_lockout_info | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "show_lockout_info", "label": "Show Lockout Source Info", "type": "boolean", "required": False, "order": 2},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "lockout"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000005"),
"slug": "delete-ad-user",
"name": "Delete AD User Account",
"description": "Permanently deletes an Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ConfirmDeletion = {{ confirm_deletion | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "confirm_deletion", "label": "Confirm Deletion", "type": "boolean", "required": True, "order": 2},
]
}),
"complexity": "advanced",
"estimated_runtime": "< 10 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "destructive"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000006"),
"slug": "bulk-user-import",
"name": "Bulk User Import from CSV",
"description": "Imports multiple Active Directory user accounts from a CSV file.",
"script_body": "$CSVPath = '{{ csv_path }}'\n$OUPath = '{{ ou_path }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "csv_path", "label": "CSV File Path", "type": "text", "required": True, "order": 1},
{"key": "ou_path", "label": "Target OU", "type": "text", "required": True, "order": 2},
]
}),
"complexity": "advanced",
"estimated_runtime": "1-2 minutes",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "bulk"]),
},
]
for tmpl in templates:
await test_db.execute(
sa.text("""
INSERT INTO script_templates (
id, category_id, name, slug, description,
script_body, parameters_schema, default_values, validation_rules,
tags, complexity, estimated_runtime, requires_elevation,
requires_modules, version, is_verified, is_active, usage_count,
created_at, updated_at
) VALUES (
:id, :category_id, :name, :slug, :description,
:script_body, CAST(:parameters_schema AS jsonb), '{}'::jsonb, '{}'::jsonb,
CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation,
'[]'::jsonb, 1, true, true, 0,
:now, :now
)
"""),
{**tmpl, "category_id": cat_id, "now": now},
)
await test_db.commit()
return cat_id
# ── Categories ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_categories_requires_auth(client):
response = await client.get("/api/v1/scripts/categories")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_categories_returns_seeded_data(client, auth_headers, seed_script_data):
response = await client.get("/api/v1/scripts/categories", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert any(c["slug"] == "active-directory" for c in data)
# ── Templates ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_templates_requires_auth(client):
response = await client.get("/api/v1/scripts/templates")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_templates_returns_seeded_data(client, auth_headers, seed_script_data):
response = await client.get("/api/v1/scripts/templates", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 6
slugs = [t["slug"] for t in data]
assert "create-ad-user" in slugs
assert "reset-ad-password" in slugs
@pytest.mark.asyncio
async def test_list_templates_filter_by_category(client, auth_headers, seed_script_data):
response = await client.get(
"/api/v1/scripts/templates?category_slug=active-directory",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert len(data) == 6
@pytest.mark.asyncio
async def test_list_templates_search(client, auth_headers, seed_script_data):
response = await client.get(
"/api/v1/scripts/templates?search=password",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert any("password" in t["name"].lower() or "password" in t["slug"] for t in data)
@pytest.mark.asyncio
async def test_get_template_detail(client, auth_headers, seed_script_data):
list_resp = await client.get("/api/v1/scripts/templates", headers=auth_headers)
templates = list_resp.json()
template_id = templates[0]["id"]
response = await client.get(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "script_body" in data
assert "parameters_schema" in data
@pytest.mark.asyncio
async def test_get_template_detail_not_found(client, auth_headers):
response = await client.get(
"/api/v1/scripts/templates/00000000-0000-0000-0000-000000000099",
headers=auth_headers,
)
assert response.status_code == 404
# ── Generate ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_generate_script_success(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=unlock",
headers=auth_headers,
)
unlock_template = list_resp.json()[0]
response = await client.post(
"/api/v1/scripts/generate",
json={
"template_id": unlock_template["id"],
"parameters": {"sam_account_name": "jsmith", "show_lockout_info": False},
},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "script" in data
assert "jsmith" in data["script"]
assert "id" in data
@pytest.mark.asyncio
async def test_generate_script_missing_required_param(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=unlock",
headers=auth_headers,
)
unlock_template = list_resp.json()[0]
response = await client.post(
"/api/v1/scripts/generate",
json={
"template_id": unlock_template["id"],
"parameters": {},
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_generate_script_password_redacted_in_record(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=reset-ad-password",
headers=auth_headers,
)
reset_template = list_resp.json()[0]
await client.post(
"/api/v1/scripts/generate",
json={
"template_id": reset_template["id"],
"parameters": {
"sam_account_name": "jsmith",
"new_password": "SuperSecret123!",
"force_change_at_logon": True,
"unlock_account": True,
},
},
headers=auth_headers,
)
history_resp = await client.get("/api/v1/scripts/generations", headers=auth_headers)
assert history_resp.status_code == 200
generations = history_resp.json()
assert len(generations) > 0
latest = generations[0]
assert latest["parameters_used"].get("new_password") == "[REDACTED]"
# ── Team template CRUD ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_team_template_requires_team_admin(client, auth_headers, seed_script_data):
list_resp = await client.get("/api/v1/scripts/categories", headers=auth_headers)
cat_id = list_resp.json()[0]["id"]
response = await client.post(
"/api/v1/scripts/templates",
json={
"category_id": cat_id,
"name": "My Custom Script",
"script_body": "Write-Host 'hello'",
"parameters_schema": {},
},
headers=auth_headers, # regular engineer
)
assert response.status_code == 403

View File

@@ -0,0 +1,157 @@
# Script Template Editor — Design Document
> **Date:** 2026-03-13
> **Status:** Approved
> **Depends on:** Script Generator Phase 1 (backend) + Phase 2 (Script Library frontend)
---
## Goal
Build a Script Template Editor page where engineers can create and manage their own PowerShell script templates, and owners/admins can promote personal templates to team-wide visibility via a "Share with team" toggle.
---
## Page Structure
**Route:** `/scripts/manage`
**Two modes:**
- **List mode** — all templates the user can see/edit. Filterable by category, searchable by name. Each row: name, category, complexity badge, usage count, scope badge ("Personal" / "Team"), action buttons (Edit, Delete).
- **Editor mode** — full-page form for creating or editing a template. No modal — the template has too many fields. "Back to templates" link with unsaved-changes warning if dirty.
**Navigation entry points:**
- "Manage Templates" link on the Script Library page header (visible to engineers+)
- Direct URL `/scripts/manage`
---
## Permissions
### Who sees what in the list
| Role | Visible templates |
|------|------------------|
| Engineer | Own templates (`created_by = user.id`) + team templates (`team_id = user.team_id`) |
| Owner / Admin | All templates in their account scope |
| Super admin | All templates across all accounts |
### Who can do what
| Action | Engineer | Owner/Admin | Super Admin |
|--------|----------|-------------|-------------|
| Create template | Yes (personal scope) | Yes (personal or team) | Yes (any scope) |
| Edit own template | Yes | Yes | Yes |
| Edit others' templates | No | Yes (within account) | Yes (all) |
| Delete own template | Yes (soft delete) | Yes | Yes |
| Delete others' templates | No | Yes (within account) | Yes (all) |
| "Share with team" toggle | No | Yes | Yes |
---
## Backend Changes
### Permission refactor
Replace `_require_team_admin()` with `_check_template_permission(user, template?)`:
- **Create:** engineers+ can create (personal scope)
- **Edit/Delete:** engineers can modify own templates (`created_by == user.id`); owners/admins can modify any template in their account; super admins can modify any
- Existing `POST /scripts/templates` sets `created_by = user.id` and `team_id = null` for engineers (personal scope)
### New endpoint
`PATCH /scripts/templates/{id}/share` — owner/admin/super_admin only
- Body: `{ "shared": boolean }`
- `shared=true` → sets `team_id` to the user's `team_id`
- `shared=false` → clears `team_id` to `null` (reverts to personal scope for original author)
- Returns updated `ScriptTemplateDetail`
### Query filter
Update `GET /scripts/templates` to support `managed=true` query param:
- Returns templates the user can edit (own templates + team templates for owners/admins)
- Used by the manage page list view
---
## Template Editor Form
Single scrollable page with sections separated by dividers. Fixed bottom action bar.
### Section 1: Metadata
- **Name** — text input, required
- **Description** — textarea
- **Use Case** — textarea ("when would you use this?")
- **Category** — select dropdown from existing categories
- **Complexity** — select: beginner / intermediate / advanced
- **Tags** — multi-text input (comma-separated or chip-style)
- **Estimated Runtime** — text input (e.g., "30 seconds")
- **Requires Elevation** — checkbox
- **Required Modules** — multi-text input
- **Share with team** — toggle switch, visible only to owners/admins/super_admins. Help text: "When enabled, all team members can browse and use this template."
### Section 2: Script Body
- Large textarea with `PowerShellHighlighter` for syntax coloring
- Monospace font (`font-label` / JetBrains Mono)
- `{{parameter_key}}` placeholders highlighted in amber so authors can see where parameters slot in
- Simple textarea with highlighting overlay (no Monaco/CodeMirror dependency)
### Section 3: Parameters Schema
Two modes with a toggle at the top of the section:
**Visual mode (default):**
- List of parameter cards, each expandable/collapsible
- "Add Parameter" button at bottom
- Each card: key, label, type (select from 7 types), required toggle, placeholder, group, order, help_text, default value, sensitive toggle
- For `select` type: options sub-list (value + label pairs)
- For types with validation: min/max/pattern fields
- Drag-to-reorder or up/down arrows for parameter ordering
**JSON mode:**
- Raw JSON editor showing the `parameters_schema` object
- Edits sync back to visual mode on switch
- Parse errors shown inline
### Section 4: Fixed Action Bar
- **Save** — primary button, creates or updates template
- **Cancel** — back to list with dirty-state warning
- **Delete** — danger button (right-aligned), only in edit mode, confirmation modal, soft delete
---
## Team Sharing Behavior
- **Default for engineer-created templates:** `team_id = null` (personal, only visible to creator)
- **Shared:** `team_id` set to account's team — template appears in Script Library for all team members
- **Unsharing:** reverts to personal scope for original author; author retains edit access via `created_by`
---
## Frontend Components (Expected)
| Component | Responsibility |
|-----------|---------------|
| `ScriptManagePage.tsx` | Page shell, list/editor mode toggle |
| `ScriptTemplateListView.tsx` | Template list with filters, search, action buttons |
| `ScriptTemplateEditor.tsx` | Full editor form — metadata, script body, parameters |
| `ParameterSchemaBuilder.tsx` | Visual parameter builder with add/remove/reorder |
| `ParameterCard.tsx` | Single parameter editor (expandable card) |
| `ParameterJsonEditor.tsx` | Raw JSON mode for parameters schema |
| `ScriptBodyEditor.tsx` | Textarea with PowerShell highlighting overlay |
| `ShareToggle.tsx` | Team sharing toggle (owner/admin only) |
---
## Design System Compliance
- Dark glassmorphism theme, `.glass-card-static` containers
- Primary actions: `bg-gradient-brand`
- Section labels: `font-label text-[0.625rem] uppercase tracking-[0.1em]`
- Form inputs: `border-border bg-card text-foreground` with cyan focus ring
- Complexity badges: emerald (beginner), amber (intermediate), rose (advanced)
- Scope badges: "Personal" (muted border) / "Team" (cyan/primary tint)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
# Parameter Detector — Design Doc
> **Date:** 2026-03-14
> **Status:** Approved
> **Scope:** Frontend only (no backend changes)
## Overview
A client-side tool in the Script Template Editor that scans a PowerShell script body, detects hardcoded values that should be parameterized, and walks the user through converting them one-by-one via a stepper UI.
## Detection Engine
Pure TypeScript utility (`lib/scriptParameterDetector.ts`) that takes a script body string and returns `ParameterCandidate[]`.
### Detection targets (in order)
1. **Script-level `param()` blocks** — the `param(...)` block at the top of the script, before any `function` keyword. Extracts name, type annotation, and default value. Skips `param()` blocks inside `function` declarations.
2. **Variable assignments**`$VarName = 'value'`, `$VarName = "value"`, `$VarName = 123`, `$VarName = $true/$false`. Skips variables already found in the param block and PowerShell internals like `$ErrorActionPreference`.
### Type inference
| Detection | Suggested Type | Sensitive |
|-----------|---------------|-----------|
| `[string]` or plain string value | `text` | no |
| `[switch]` or `$true`/`$false` | `boolean` | no |
| `[int]`, `[int32]`, `[int64]` or numeric value | `number` | no |
| `[SecureString]` or name contains password/secret/key/credential | `password` | yes |
| No type info, string value | `text` | no |
### Candidate shape
```typescript
interface ParameterCandidate {
variableName: string // "$OUPath"
suggestedKey: string // "ou_path"
suggestedLabel: string // "OU Path"
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string // "Detected [switch] type declaration"
}
```
## Stepper UI
`ParameterDetectorStepper` component renders inline below the ScriptBodyEditor in the Script Body section.
### Trigger
- "Detect Parameters" button (secondary style, Wand2/Scan icon) below the script body textarea
- Hidden if script body is empty; disabled while stepper is active
- If no candidates found: brief "No parameter candidates detected" message
### Stepper layout
Shows one candidate at a time with:
- Progress indicator ("Candidate 2 of 5" + dots)
- Matched line displayed in monospace
- Editable fields: Key, Label, Type (with info icon showing inferenceReason), Default value
- Checkboxes: Required, Sensitive
- Actions: Skip, Accept & Next (last item: Accept & Finish / Skip & Finish)
### On accept
1. Script body: replace the hardcoded value with `{{key}}`
2. Parameters schema: append a new `ScriptParameter` with suggested values + original value as `default`
### Edge cases
- Script body edited during detection → stepper dismisses
- Key conflicts with existing parameter → warning + suggested alternative
- Re-running after partial conversion → skips already-converted `{{key}}` placeholders
## Integration
### Component tree
```
ScriptTemplateEditor
├── Metadata section
├── Script Body section
│ ├── ScriptBodyEditor
│ ├── "Detect Parameters" button ← NEW
│ └── ParameterDetectorStepper ← NEW (conditional)
├── Parameters section
│ └── ParameterSchemaBuilder
└── Fixed Action Bar
```
### Data flow
- Detection runs client-side via `detectParameterCandidates(script_body)`
- Candidates stored in local React state on ScriptTemplateEditor
- Accept updates `form.script_body` and `form.parameters_schema` via existing `updateField()`
- `isDirty` flag set automatically — user can cancel without saving to undo everything
- No new backend endpoints needed
## File changes
**New files:**
- `frontend/src/lib/scriptParameterDetector.ts`
- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Modified files:**
- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
- `frontend/src/types/scripts.ts`
## Out of scope
- No backend changes
- No AI-powered detection (future enhancement)
- No auto-detection on paste
- No individual undo for accepted parameters

View File

@@ -0,0 +1,920 @@
# Parameter Detector Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a client-side PowerShell parameter detection tool to the Script Template Editor that scans script bodies for hardcoded values and walks users through converting them to template parameters via a stepper UI.
**Architecture:** Pure frontend feature. A detection engine (`lib/scriptParameterDetector.ts`) parses PowerShell script bodies using regex to find script-level `param()` block entries and variable assignments. A stepper component (`ParameterDetectorStepper`) presents candidates one-by-one for review. Accepted candidates update both `form.script_body` (value → `{{key}}`) and `form.parameters_schema` (new `ScriptParameter` appended).
**Tech Stack:** TypeScript, React, Lucide icons, Tailwind CSS, existing ScriptParameter types
**Design doc:** `docs/plans/2026-03-14-parameter-detector-design.md`
---
### Task 1: Add ParameterCandidate type
**Files:**
- Modify: `frontend/src/types/scripts.ts:124` (append after `ScriptTemplateUpdateRequest`)
**Step 1: Add the interface**
Add to the end of `frontend/src/types/scripts.ts`:
```typescript
export interface ParameterCandidate {
variableName: string
suggestedKey: string
suggestedLabel: string
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string
}
```
**Step 2: Export from types index**
Verify `ParameterCandidate` is exported from `frontend/src/types/index.ts`. If scripts types are re-exported with `export * from './scripts'`, it's automatic. Otherwise add the export.
**Step 3: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 4: Commit**
```bash
git add frontend/src/types/scripts.ts
git commit -m "feat: add ParameterCandidate type for script parameter detection"
```
---
### Task 2: Build detection engine
**Files:**
- Create: `frontend/src/lib/scriptParameterDetector.ts`
**Step 1: Create the detection utility**
Create `frontend/src/lib/scriptParameterDetector.ts` with the following:
```typescript
import type { ScriptParameter, ParameterCandidate } from '@/types'
/**
* PowerShell variable names to skip — these are PS internals, not user inputs.
*/
const SKIP_VARIABLES = new Set([
'$ErrorActionPreference',
'$WarningPreference',
'$VerbosePreference',
'$DebugPreference',
'$InformationPreference',
'$ConfirmPreference',
'$ProgressPreference',
'$PSDefaultParameterValues',
'$PSModuleAutoLoadingPreference',
'$OFS',
'$FormatEnumerationLimit',
'$MaximumHistoryCount',
'$_',
'$PSItem',
'$args',
'$input',
'$this',
'$null',
'$true',
'$false',
])
/**
* Sensitive variable name patterns — if the variable name contains any of these,
* suggest password type and mark sensitive.
*/
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
/**
* Convert a PowerShell variable name to a snake_case key.
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
*/
function toSnakeCase(varName: string): string {
// Strip leading $
const name = varName.replace(/^\$/, '')
// Insert underscore before uppercase letters, then lowercase everything
return name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
}
/**
* Convert a snake_case key to a human-readable label.
* "ou_path" → "OU Path", "server_name" → "Server Name"
*/
function toLabel(key: string): string {
return key
.split('_')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/**
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
*/
function inferType(
typeAnnotation: string | null,
value: string | null,
varName: string
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
// Check type annotation first
if (typeAnnotation) {
const t = typeAnnotation.toLowerCase()
if (t === 'switch') {
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
}
if (t === 'securestring') {
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
}
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
if (t === 'bool' || t === 'boolean') {
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
// [string] or other → fall through to value/name checks
}
// Check variable name for sensitive patterns
if (SENSITIVE_PATTERNS.test(varName)) {
return { type: 'password', sensitive: true, reason: `Variable name suggests sensitive data — marked as sensitive` }
}
// Check value patterns
if (value !== null) {
const trimmed = value.trim()
if (trimmed === '$true' || trimmed === '$false') {
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
}
}
// Default
const reason = typeAnnotation
? `Detected [${typeAnnotation}] type declaration`
: 'Defaulting to text (no type annotation detected)'
return { type: 'text', sensitive: false, reason }
}
/**
* Parse the default value into the correct JS type.
*/
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
if (value === null) return null
const trimmed = value.trim()
if (type === 'boolean') {
if (trimmed === '$true') return true
if (trimmed === '$false') return false
return null
}
if (type === 'number') {
const n = Number(trimmed)
return isNaN(n) ? null : n
}
// Strip surrounding quotes for string values
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1)
}
return trimmed
}
/**
* Find the end index of a script-level param() block.
* Returns -1 if no script-level param block is found.
* Skips param() blocks inside function declarations.
*/
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
const lines = script.split('\n')
let inFunction = false
let paramStart = -1
let parenDepth = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
// Track function blocks — skip param() inside functions
if (/^function\s+/i.test(trimmed)) {
inFunction = true
continue
}
// Find script-level param keyword
if (!inFunction && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
paramStart = i
// Count parens to find the closing )
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === '(') parenDepth++
if (ch === ')') parenDepth--
if (parenDepth === 0 && paramStart !== -1) {
return { start: paramStart, end: j }
}
}
}
}
// Reset function tracking at closing brace (simplified)
if (inFunction && trimmed === '}') {
inFunction = false
}
}
return null
}
/**
* Extract parameter candidates from a script-level param() block.
*/
function extractParamBlockCandidates(
script: string,
block: { start: number; end: number }
): ParameterCandidate[] {
const lines = script.split('\n')
const blockText = lines.slice(block.start, block.end + 1).join('\n')
const candidates: ParameterCandidate[] = []
// Match patterns like: [string]$VarName = "default" or $VarName or [switch]$VarName
// Supports [Parameter(Mandatory=$true)] attributes on preceding lines
const paramRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,\s*$|\s*$|\s*\))/gm
let match: RegExpExecArray | null
while ((match = paramRegex.exec(blockText)) !== null) {
const typeAnnotation = match[1] || null
const varName = match[2]
const rawDefault = match[3]?.trim() ?? null
// Skip Parameter() attributes — they look like [Parameter(...)]
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
const defaultValue = parseDefault(rawDefault, type)
// Find the actual line number in the original script
const lineIndex = lines.findIndex((line, idx) =>
idx >= block.start && idx <= block.end && line.includes(`$${varName}`)
)
candidates.push({
variableName: `$${varName}`,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'param_block',
lineNumber: lineIndex !== -1 ? lineIndex + 1 : block.start + 1,
matchedLine: lineIndex !== -1 ? lines[lineIndex].trim() : `$${varName}`,
inferenceReason: reason,
})
}
return candidates
}
/**
* Extract parameter candidates from variable assignments ($Var = 'value').
*/
function extractAssignmentCandidates(
script: string,
existingVarNames: Set<string>
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
const seenVars = new Set<string>()
// Match: $VarName = 'value' | "value" | 123 | $true | $false
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(assignRegex)
if (!match) continue
const fullVar = match[1]
const rawValue = match[2].trim()
// Skip PS internals
if (SKIP_VARIABLES.has(fullVar)) continue
// Skip if already found in param block
if (existingVarNames.has(fullVar)) continue
// Skip if already seen (take first assignment only)
if (seenVars.has(fullVar)) continue
// Skip if value is a complex expression (function call, pipeline, etc.)
// Only match: quoted strings, numbers, $true/$false
if (!/^['"].*['"]$/.test(rawValue) &&
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
!/^\$(true|false)$/i.test(rawValue)) {
continue
}
// Skip if the value already contains a {{placeholder}}
if (/\{\{.*?\}\}/.test(rawValue)) continue
seenVars.add(fullVar)
const varName = fullVar.replace(/^\$/, '')
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(null, rawValue, varName)
const defaultValue = parseDefault(rawValue, type)
candidates.push({
variableName: fullVar,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'assignment',
lineNumber: i + 1,
matchedLine: lines[i].trim(),
inferenceReason: reason,
})
}
return candidates
}
/**
* Detect parameter candidates in a PowerShell script body.
* Returns candidates from script-level param() block first, then variable assignments.
*/
export function detectParameterCandidates(script: string): ParameterCandidate[] {
if (!script.trim()) return []
// 1. Find and extract script-level param block
const paramBlock = findScriptLevelParamBlock(script)
const paramCandidates = paramBlock
? extractParamBlockCandidates(script, paramBlock)
: []
// Track param block var names to avoid duplicates in assignment scan
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
// 2. Extract variable assignments
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
return [...paramCandidates, ...assignmentCandidates]
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/lib/scriptParameterDetector.ts
git commit -m "feat: add PowerShell parameter detection engine"
```
---
### Task 3: Build ParameterDetectorStepper component
**Files:**
- Create: `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
**Step 1: Create the stepper component**
Create `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`:
```tsx
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ 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' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
// Editable overrides for the current candidate
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const keyConflict = existingKeys.includes(key) ||
candidates.slice(0, currentIndex).some((_, i) => {
// This is a simplification — actual conflict check happens against
// the running list of accepted keys which is managed by the parent
return false
})
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
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)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}
```
**Step 2: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 3: Commit**
```bash
git add frontend/src/components/script-editor/ParameterDetectorStepper.tsx
git commit -m "feat: add ParameterDetectorStepper component"
```
---
### Task 4: Wire detection into ScriptTemplateEditor
**Files:**
- Modify: `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
**Step 1: Add imports**
At the top of `ScriptTemplateEditor.tsx`, add:
```typescript
import { Scan } from 'lucide-react'
import { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type { ParameterCandidate, ScriptParameter } from '@/types'
```
Update the existing `lucide-react` import to include `Scan` alongside the existing icons.
**Step 2: Add detection state**
Inside the `ScriptTemplateEditor` component, after the existing `useState` declarations (around line 59), add:
```typescript
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
```
**Step 3: Add detection handler**
After `handleBack` (around line 188), add:
```typescript
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
// 1. Replace the value in the script body with {{key}}
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
// For param block: replace the default value portion
// e.g., $VarName = "default" → $VarName = "{{key}}"
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
// For assignment: replace the right-hand side value
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
// 2. Append new parameter to the schema
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
// Update both fields
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
```
**Step 4: Add UI elements to the Script Body section**
In the JSX, find the Script Body section (around line 334-348). After the `<ScriptBodyEditor>` and before `</section>`, add the detect button and stepper:
```tsx
<ScriptBodyEditor
value={form.script_body}
onChange={v => updateField('script_body', v)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
```
**Step 5: Run build to verify**
Run: `cd frontend && npm run build`
Expected: SUCCESS
**Step 6: Commit**
```bash
git add frontend/src/components/script-editor/ScriptTemplateEditor.tsx
git commit -m "feat: wire parameter detection into ScriptTemplateEditor"
```
---
### Task 5: Manual testing checklist
**Step 1: Test with variable assignments**
1. Navigate to `/scripts/manage` → click New Template
2. Paste this script body:
```powershell
$ServerName = 'DC01'
$OUPath = 'OU=Users,DC=contoso,DC=com'
$DefaultPassword = 'Welcome123!'
$ForceChange = $true
$MaxRetries = 3
```
3. Click "Detect Parameters"
4. Verify 5 candidates appear in stepper
5. Verify type inference: ServerName=text, OUPath=text, DefaultPassword=password+sensitive, ForceChange=boolean, MaxRetries=number
6. Accept all — verify script body has `{{key}}` placeholders and Parameters section has 5 entries with defaults preserved
**Step 2: Test with param() block**
Paste:
```powershell
param(
[string]$ServerName = "DC01",
[switch]$WhatIf,
[SecureString]$AdminPassword,
[int]$Port = 443
)
$Connection = "https://$ServerName:$Port"
```
Expected: 4 candidates from param block (ServerName, WhatIf, AdminPassword, Port) + 0 from assignments (Connection is a complex expression, not a simple literal)
**Step 3: Test with function-level param (should be skipped)**
Paste:
```powershell
$GlobalPath = 'C:\Scripts'
function Load-Users {
param($filter = "*")
Get-ADUser -Filter $filter
}
```
Expected: 1 candidate only (GlobalPath). The function-level `param($filter)` should NOT appear.
**Step 4: Test edge cases**
- Empty script body → Detect Parameters button hidden
- Script with only `{{key}}` placeholders → "No parameter candidates detected"
- Script with PS internals like `$ErrorActionPreference = 'Stop'` → skipped
- Re-running detect after accepting some → already-converted values skipped
**Step 5: Commit any fixes**
```bash
git commit -m "fix: address issues found during parameter detector testing"
```
---
### Task 6: Final build verification and push
**Step 1: Run full build**
Run: `cd frontend && npm run build`
Expected: SUCCESS with no type errors
**Step 2: Push**
```bash
git push
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,688 @@
# Script Library Pane Takeover — Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign the Script Library left pane to have Browse mode (template list + filter bar) and Configure mode (full-height parameter form + action buttons), with the right pane becoming a read-only `ScriptPreview`.
**Architecture:** `ScriptLibraryPage` owns `paneMode` local state (`'browse' | 'configure'`). Clicking "Configure →" on a `TemplateCard` calls `store.selectTemplate(id)` then flips the pane. `ScriptConfigurePane` (new component) owns the configure-mode layout — back button, template header, param form, action bar. `ScriptGeneratorPanel` is deleted; the right pane becomes `ScriptPreview` in isolation.
**Tech Stack:** React 19, TypeScript, Zustand (`useScriptGeneratorStore`), Tailwind CSS v3, Lucide React. Verification: `npx tsc -b --noEmit` (NOT `npm run build` — pre-existing Node 18 incompatibility with Vite).
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `frontend/src/components/scripts/TemplateCard.tsx` | Modify | Non-interactive card with "Configure →" button; no store subscription |
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Modify | Thread `onConfigure` prop to each `TemplateCard` |
| `frontend/src/components/scripts/ScriptConfigurePane.tsx` | Create | Configure mode layout: back button, template header, form, action bar |
| `frontend/src/pages/ScriptLibraryPage.tsx` | Modify | `paneMode` state, filter-bar moved into left pane, right pane simplified |
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | Delete | Superseded by `ScriptConfigurePane` + right-pane simplification |
---
## Chunk 1: All Tasks
### Task 1: Modify `TemplateCard` — remove store subscription, add "Configure →" button
**Files:**
- Modify: `frontend/src/components/scripts/TemplateCard.tsx`
Current state: `TemplateCard` is a `<button>` that calls `store.selectTemplate()` on click and applies active-border styling when `selectedTemplate?.id === template.id`. It imports `useScriptGeneratorStore`.
- [ ] **Step 1: Replace the entire file with the updated implementation**
```tsx
import { ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
}
interface Props {
template: ScriptTemplateListItem
onConfigure: (id: string) => void
}
export function TemplateCard({ template, onConfigure }: Props) {
return (
<div
className={cn(
'w-full text-left px-4 py-3 rounded-xl border transition-all',
'border-border bg-transparent'
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-foreground line-clamp-1">
{template.name}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && (
<span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" />
</span>
)}
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
{template.complexity}
</span>
</div>
</div>
{template.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{template.description}
</p>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
<span>{template.usage_count}× used</span>
{template.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{template.tags.slice(0, 3).map(tag => (
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
{tag}
</span>
))}
{template.tags.length > 3 && (
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
)}
</div>
)}
</div>
<button
type="button"
onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
>
Configure
</button>
</div>
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: errors only from `ScriptTemplateList` (it still passes no `onConfigure` prop) — that's fine, it's fixed in Task 2.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/TemplateCard.tsx
git commit -m "refactor: TemplateCard — remove store subscription, add Configure button"
```
---
### Task 2: Modify `ScriptTemplateList` — thread `onConfigure` prop
**Files:**
- Modify: `frontend/src/components/scripts/ScriptTemplateList.tsx`
Current state: `ScriptTemplateList` accepts `{ inputValue, onClearSearch }` and renders `<TemplateCard template={template} />` with no extra props.
- [ ] **Step 1: Update the file**
```tsx
import { FileCode, Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { TemplateCard } from './TemplateCard'
interface Props {
inputValue: string
onClearSearch: () => void
onConfigure: (id: string) => void
}
function TemplateSkeleton() {
return (
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
<div className="flex justify-between mb-2">
<div className="h-4 w-2/3 bg-white/10 rounded" />
<div className="h-4 w-14 bg-white/10 rounded" />
</div>
<div className="h-3 w-full bg-white/5 rounded mb-1" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
)
}
export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: Props) {
const templates = useScriptGeneratorStore(s => s.templates)
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
if (isLoadingTemplates) {
return (
<div className="flex flex-col gap-2 p-2">
<TemplateSkeleton />
<TemplateSkeleton />
<TemplateSkeleton />
</div>
)
}
if (templates.length === 0) {
if (inputValue !== '') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<Search size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates match your search</p>
<button
type="button"
onClick={onClearSearch}
className="text-xs text-primary hover:underline"
>
Clear search
</button>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates found</p>
</div>
)
}
return (
<div className="flex flex-col gap-2 p-2">
{templates.map(template => (
<TemplateCard key={template.id} template={template} onConfigure={onConfigure} />
))}
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: errors now only from `ScriptLibraryPage` (it passes no `onConfigure` to `ScriptTemplateList` yet) — that's fine.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptTemplateList.tsx
git commit -m "refactor: ScriptTemplateList — add onConfigure prop threading"
```
---
### Task 3: Create `ScriptConfigurePane` — configure mode layout
**Files:**
- Create: `frontend/src/components/scripts/ScriptConfigurePane.tsx`
This new component renders the full configure-mode left pane: back button, loading spinner (when `isLoadingDetail`), first-selection error state, template header with tags, `ScriptParameterForm`, warnings callout, and the Generate/Download/Copy action bar.
Data comes from `useScriptGeneratorStore` directly. `canGenerate` and `onBack` come from props.
The action-bar Copy button copies `store.generatedScript` and is disabled when `generatedScript === null`. The Download button uses `selectedTemplate.slug` for the filename. Both Generate and Download are disabled when `isGenerating || !canGenerate`. Copy is disabled when `!generatedScript || !canGenerate`.
- [ ] **Step 1: Create the file**
```tsx
import { useState } from 'react'
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterForm } from './ScriptParameterForm'
const COMPLEXITY_CLASSES = {
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
} as const
interface Props {
canGenerate: boolean
onBack: () => void
}
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
const categories = useScriptGeneratorStore(s => s.categories)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
const generateError = useScriptGeneratorStore(s => s.generateError)
const generate = useScriptGeneratorStore(s => s.generate)
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!generatedScript) return
try {
await navigator.clipboard.writeText(generatedScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail
}
}
const handleDownload = () => {
if (!generatedScript || !selectedTemplate) return
const blob = new Blob([generatedScript], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedTemplate.slug}.ps1`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Loading state
if (isLoadingDetail) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex items-center justify-center">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
</div>
)
}
// First-selection failure state
if (!selectedTemplate) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
<Terminal size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Failed to load template.</p>
</div>
</div>
)
}
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
const displayTags = selectedTemplate.tags.slice(0, 3)
const extraTagCount = selectedTemplate.tags.length - 3
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
{/* Template header */}
<div className="mb-3">
<h2 className="text-base font-semibold font-heading text-foreground">
{selectedTemplate.name}
</h2>
{selectedTemplate.description && (
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
)}
<div className="flex items-center gap-1.5 flex-wrap mt-2">
{selectedTemplate.requires_elevation && (
<span
title="Requires administrator elevation"
className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
>
Elevated
</span>
)}
<span className={cn(
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
COMPLEXITY_CLASSES[selectedTemplate.complexity]
)}>
{selectedTemplate.complexity}
</span>
{categoryName && (
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{categoryName}
</span>
)}
{displayTags.map(tag => (
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{tag}
</span>
))}
{extraTagCount > 0 && (
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
)}
</div>
</div>
<div className="border-t border-border mb-4" />
{/* Parameter form */}
<ScriptParameterForm canGenerate={canGenerate} />
{/* Warnings */}
{generationWarnings.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3 mt-4">
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
<AlertTriangle size={13} />
Warnings
</div>
{generationWarnings.map((w, i) => (
<p key={i} className="text-xs text-amber-400/80">{w}</p>
))}
</div>
)}
{/* Action bar */}
<div className="flex flex-col gap-2 mt-4 pt-1">
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating || !canGenerate}
className="w-full flex items-center justify-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 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
>
{isGenerating && <Loader2 size={14} className="animate-spin" />}
Generate Script
</button>
</span>
<div className="flex gap-2">
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleDownload}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} />
Download .ps1
</button>
</span>
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleCopy}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</span>
</div>
</div>
{/* Generate error */}
{generateError && (
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
)}
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: No new errors from this file. Errors may still exist from `ScriptLibraryPage` not yet updated — that's fine.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/scripts/ScriptConfigurePane.tsx
git commit -m "feat: add ScriptConfigurePane — configure mode layout"
```
---
### Task 4: Rewrite `ScriptLibraryPage` — pane mode, filter bar in column, right pane simplified
**Files:**
- Modify: `frontend/src/pages/ScriptLibraryPage.tsx`
Changes:
1. Add `paneMode` state (`'browse' | 'configure'`)
2. Add `usePermissions` for `canGenerate`
3. Add `selectedTemplate` subscription for right-pane conditional
4. Move `ScriptFilterBar` into the left pane column (only rendered in browse mode)
5. Add `onConfigure` / `onBack` callbacks
6. Left pane conditionally renders browse content or `ScriptConfigurePane`
7. Right pane: empty state when `selectedTemplate === null`, otherwise `ScriptPreview` in `overflow-hidden` wrapper
- [ ] **Step 1: Replace the entire file**
```tsx
import { useState, useEffect } from 'react'
import { Terminal } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
export default function ScriptLibraryPage() {
const [inputValue, setInputValue] = useState('')
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const { isEngineer } = usePermissions()
const canGenerate = isEngineer
useEffect(() => {
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
const onClearSearch = () => {
setInputValue('')
setSearch('')
}
const onConfigure = (id: string) => {
selectTemplate(id)
setPaneMode('configure')
}
const onBack = () => {
clearOutput()
setPaneMode('browse')
}
return (
<div className="flex flex-col gap-4 p-6 h-full">
{/* Page header */}
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
</div>
{/* Two-column layout */}
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
{/* Left pane — Browse or Configure */}
{paneMode === 'browse' ? (
<div className="glass-card-static flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
</div>
<div className="flex-1 overflow-y-auto">
<ScriptTemplateList
inputValue={inputValue}
onClearSearch={onClearSearch}
onConfigure={onConfigure}
/>
</div>
</div>
) : (
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
)}
{/* Right pane — always ScriptPreview */}
{selectedTemplate === null ? (
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Terminal size={40} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Select a template to get started</p>
</div>
) : (
<div className="glass-card-static h-full overflow-hidden p-4">
<ScriptPreview />
</div>
)}
</div>
</div>
)
}
```
- [ ] **Step 2: Verify TypeScript — expect clean**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: Zero errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/pages/ScriptLibraryPage.tsx
git commit -m "feat: ScriptLibraryPage — pane takeover with Browse/Configure modes"
```
---
### Task 5: Delete `ScriptGeneratorPanel`
**Files:**
- Delete: `frontend/src/components/scripts/ScriptGeneratorPanel.tsx`
`ScriptGeneratorPanel` is no longer imported or used anywhere — `ScriptLibraryPage` now uses `ScriptConfigurePane` for the left pane and `ScriptPreview` directly for the right pane.
- [ ] **Step 1: Verify nothing imports `ScriptGeneratorPanel`**
```bash
grep -r "ScriptGeneratorPanel" /home/michaelchihlas/dev/patherly/frontend/src
```
Expected: No output (zero matches).
- [ ] **Step 2: Delete the file**
```bash
rm /home/michaelchihlas/dev/patherly/frontend/src/components/scripts/ScriptGeneratorPanel.tsx
```
- [ ] **Step 3: Verify TypeScript still clean**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc -b --noEmit
```
Expected: Zero errors.
- [ ] **Step 4: Commit**
```bash
git add -u frontend/src/components/scripts/ScriptGeneratorPanel.tsx
git commit -m "chore: delete ScriptGeneratorPanel — superseded by ScriptConfigurePane"
```
---
### Task 6: Smoke test
- [ ] **Step 1: Start dev server**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
```
- [ ] **Step 2: Verify browse mode**
Open `http://localhost:5173/scripts`.
Expected:
- Template list shows with filter bar above it (inside the left pane, no filter bar outside the pane)
- Each template card has a "Configure →" button at bottom-right
- Clicking anywhere on the card body (not the button) does nothing
- Right pane shows Terminal icon + "Select a template to get started"
- [ ] **Step 3: Verify configure mode**
Click "Configure →" on any template.
Expected:
- Left pane transitions to configure view (filter bar and list hidden)
- Loading spinner visible briefly, then full configure view appears
- Template name, description, complexity badge, category name, tags visible at top
- Parameter form below (all fields interactive if engineer role)
- "Generate Script" button full-width, cyan gradient
- "Download .ps1" and "Copy" buttons disabled (no generated script yet)
- Right pane still shows empty state (first click) or previous preview
- [ ] **Step 4: Verify generate flow**
Fill required parameters, click "Generate Script".
Expected:
- Spinner appears on Generate button during generation
- Right pane updates to show generated PowerShell (with syntax highlighting)
- "Download .ps1" and "Copy" buttons become enabled
- Copy button copies text; shows "Copied!" for 2 seconds
- Download button triggers `.ps1` file download
- [ ] **Step 5: Verify Back**
Click "← Back to library".
Expected:
- Left pane returns to browse mode (filter bar + template list visible)
- Search input and category pills restore to previous state
- Right pane continues showing the previously generated output
- [ ] **Step 6: Stop dev server and push**
```bash
git push origin feat/script-generator
```

View File

@@ -0,0 +1,410 @@
# Script Generator Phase 2 — Frontend Design
**Date:** 2026-03-13
**Status:** Approved
**Phase:** 2 of Script Generator feature
**Builds on:** Phase 1 backend (`feat/script-generator` PR #105)
---
## Goal
Build the Script Library frontend: a page where MSP engineers can browse PowerShell script templates by category, fill in parameters, get a live preview, and generate + copy/download the final script.
---
## Architecture
**Layout:** Top filter bar + two-column layout. Category tabs and search across the top, template list on the left, generator panel on the right. Follows the existing glassmorphism design system.
**State:** Zustand store (`scriptGeneratorStore`) chosen over a local hook because script generation will soon be embeddable in session execution (Script Output Node). A store allows any component anywhere in the tree to read/write generation state without prop drilling. Survives navigation — stale state is intentional and supports Phase 3 embeddability. `reset()` is a public action exposed for Phase 3 callers (e.g. session execution context that needs to clear output between runs). Within Phase 2, it is never called automatically; `selectTemplate()` clears form/output state inline via its own atomic `set()` call without calling `reset()`.
**Preview:** Client-side lightweight substitution for live preview as the user types (`{{key}}` replacement only, matching the backend template variable syntax). The real `POST /scripts/generate` endpoint is called on Generate — it applies filters, conditionals, and PowerShell-safe sanitization on the backend.
**Security note:** The viewer restriction on Generate/Download is enforced frontend-only in Phase 2. The backend `POST /scripts/generate` endpoint uses only `get_current_active_user` with no role check. Adding a backend engineer role guard is deferred — this is a known limitation.
**Search note:** The backend `?search=` parameter matches against `name`, `description`, and `slug`. Tags are filtered separately via `?tags=` (comma-separated). Phase 2 does not expose a tag filter UI — search covers name/description/slug only.
---
## Zustand Store — `scriptGeneratorStore`
**File:** `frontend/src/store/scriptGeneratorStore.ts`
### State shape
```typescript
interface ScriptGeneratorState {
// Template browsing
categories: ScriptCategoryResponse[];
templates: ScriptTemplateListItem[];
selectedTemplate: ScriptTemplateDetail | null;
searchQuery: string;
activeCategoryId: string | null; // null = "All"; used to derive slug for API calls
isLoadingTemplates: boolean; // drives skeleton in ScriptTemplateList only
isLoadingDetail: boolean; // drives spinner in ScriptGeneratorPanel only
// Form
paramValues: Record<string, string>; // keyed by ScriptParameter.key; booleans stored as 'true'/'false'
formErrors: Record<string, string>; // keyed by ScriptParameter.key
// Output
generatedScript: string | null;
generationId: string | null;
generationWarnings: string[];
isGenerating: boolean;
generateError: string | null;
// Actions
loadCategories: () => Promise<void>;
loadTemplates: () => Promise<void>;
selectTemplate: (id: string) => Promise<void>;
setCategory: (id: string | null) => void;
setSearch: (query: string) => void;
setParamValue: (key: string, value: string) => void;
validate: () => boolean;
generate: (sessionId?: string) => Promise<void>;
clearOutput: () => void;
reset: () => void;
}
```
### Behaviour notes
- `setCategory` updates `activeCategoryId`, then calls `loadTemplates()`
- `setSearch` updates `searchQuery`, then calls `loadTemplates()`. The component (not the store) debounces its call to `setSearch``setSearch` always calls `loadTemplates()` immediately on invocation
- `loadTemplates()` resolves the slug from the `categories` array (`categories.find(c => c.id === activeCategoryId)?.slug`) before sending `{ category_slug, search }` to the API. When `activeCategoryId` is `null` ("All"), `category_slug` is omitted from the request. Prerequisite: `loadCategories()` must complete before `loadTemplates()` or `setCategory()` can resolve slugs — the page bootstrap calls them in this order
- `selectTemplate(id)` workflow:
1. Sets `isLoadingDetail: true` (does NOT touch `isLoadingTemplates`)
2. Fetches `ScriptTemplateDetail` from the API
3. In a single `set()` call: sets `selectedTemplate`, populates `paramValues` by converting each `parameter.default` to string (`null``''`, `true``'true'`, `false``'false'`, numbers → `String(n)`), clears `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`, sets `isLoadingDetail: false`
4. The template list remains fully visible and interactive throughout
- `reset()` clears exactly: `paramValues`, `formErrors`, `generatedScript`, `generationId`, `generationWarnings`, `generateError`. Does NOT clear `selectedTemplate`, `categories`, `templates`, or any browsing state. Not called by `selectTemplate()` — that action handles its own inline clear. Exposed as a public store action for Phase 3 callers
- `validate()`: if `selectedTemplate` is `null`, returns `true` immediately (nothing to validate). Otherwise iterates `(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters` (cast required — backend types `parameters_schema` as `dict`; see `ScriptTemplateDetail` type comment), checks `required && !paramValues[key]` for each, writes errors to `formErrors` via `set()`, returns `false` if any required param is missing, `true` otherwise. Client-side validation of `ScriptParameterValidation` fields (`pattern`, `min_length`, `max_length`, `min_value`, `max_value`) is NOT implemented in Phase 2 — only required-field presence is checked. The backend validates these constraints on `POST /scripts/generate` and returns errors in `detail`. Phase 2 does not render per-field errors from the backend — all backend validation errors surface as a single `generateError` below the action bar
- `generate(sessionId?)`: if `selectedTemplate` is `null`, returns immediately (no-op). Otherwise calls `validate()` first — if it returns `false`, stops (errors are already in store). If valid, sets `isGenerating: true`, clears `generateError`, calls `POST /scripts/generate`. On success: sets `generatedScript`/`generationId`/`generationWarnings`, sets `isGenerating: false`. On error: extracts `error.response?.data?.detail` (FastAPI detail string) or falls back to `'Failed to generate script'`, sets `generateError`, sets `isGenerating: false`
- On generate success: `generatedScript` = `response.script`, `generationId` = `response.id`, `generationWarnings` = `response.warnings`
---
## Types
Added to `frontend/src/types/index.ts`:
```typescript
export interface ScriptCategoryResponse {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
sort_order: number;
template_count: number;
}
export interface ScriptTemplateListItem {
id: string;
category_id: string;
team_id: string | null;
name: string;
slug: string;
description: string | null;
tags: string[];
complexity: 'beginner' | 'intermediate' | 'advanced'; // must match backend ScriptComplexity enum exactly
estimated_runtime: string | null;
requires_elevation: boolean;
requires_modules: string[];
is_verified: boolean;
usage_count: number;
}
export interface ScriptParameterOption {
value: string;
label: string;
}
export interface ScriptParameterValidation {
min_value?: number; // matches backend field name (not 'min')
max_value?: number; // matches backend field name (not 'max')
pattern?: string;
min_length?: number;
max_length?: number;
}
export interface ScriptParameter {
key: string;
label: string;
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea';
required: boolean;
placeholder: string | null;
group: string | null;
order: number;
help_text: string | null;
options: ScriptParameterOption[] | null; // for select type: [{value, label}]
default: string | boolean | number | null;
validation: ScriptParameterValidation | null;
sensitive: boolean;
}
export interface ScriptParametersSchema {
parameters: ScriptParameter[];
}
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
use_case: string | null;
script_body: string;
// NOTE: backend types this as `dict` — FastAPI serializes it as plain JSON.
// At runtime this arrives as `unknown`. Access via a helper:
// function getParameters(detail: ScriptTemplateDetail): ScriptParameter[] {
// return ((detail.parameters_schema as ScriptParametersSchema)?.parameters ?? [])
// }
parameters_schema: ScriptParametersSchema;
default_values: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
validation_rules: Record<string, unknown>; // template-level metadata from backend; not used by Phase 2 UI
version: number;
created_at: string;
updated_at: string;
}
export interface ScriptGenerateRequest {
template_id: string;
parameters: Record<string, unknown>;
session_id?: string;
}
export interface ScriptGenerateResponse {
id: string; // generation UUID
script: string; // rendered PowerShell
warnings: string[];
metadata: {
template_name: string;
template_version: number;
requires_elevation: boolean;
[key: string]: unknown;
};
}
export interface ScriptGenerationRecord {
id: string;
template_id: string;
template_name: string;
parameters_used: Record<string, unknown>; // sensitive values already redacted by backend
created_at: string;
}
```
---
## API Client
**File:** `frontend/src/api/scripts.ts` — use a named export object (matching the `copilotApi`/`assistantChatApi` pattern in `api/index.ts`):
```typescript
// scripts.ts
export const scriptsApi = {
getCategories(): Promise<ScriptCategoryResponse[]> { ... },
getTemplates(params?: { category_slug?: string; search?: string; tags?: string }): Promise<ScriptTemplateListItem[]> { ... },
getTemplateDetail(id: string): Promise<ScriptTemplateDetail> { ... },
generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> { ... },
getGenerations(): Promise<ScriptGenerationRecord[]> { ... },
}
```
Re-exported from `frontend/src/api/index.ts` as:
```typescript
export { scriptsApi } from './scripts'
```
All methods use the existing `apiClient` (base URL `/api/v1`, auth interceptor handles token refresh).
> `getGenerations()` and the `tags` param on `getTemplates` are Phase 3 scaffolding — included because the backend endpoints already exist and adding them later would require touching the same file. Neither is called by the Phase 2 UI. Mark both with a `// Phase 3` comment in the implementation.
---
## Component Tree
```text
ScriptLibraryPage pages/ScriptLibraryPage.tsx
├── ScriptFilterBar components/scripts/ScriptFilterBar.tsx
├── ScriptTemplateList components/scripts/ScriptTemplateList.tsx
│ └── TemplateCard components/scripts/TemplateCard.tsx
└── ScriptGeneratorPanel components/scripts/ScriptGeneratorPanel.tsx
├── ScriptParameterForm components/scripts/ScriptParameterForm.tsx
│ └── ScriptParameterField components/scripts/ScriptParameterField.tsx
└── ScriptPreview components/scripts/ScriptPreview.tsx
└── PowerShellHighlighter components/scripts/PowerShellHighlighter.tsx
```
### Component responsibilities
**`ScriptLibraryPage`**
- Bootstraps store on mount: calls `loadCategories()` then `loadTemplates()`
- Owns `inputValue` state (search input text) and `onClearSearch` callback — lifted here so `ScriptFilterBar` and `ScriptTemplateList` can coordinate clear-search without direct coupling
- Renders `ScriptFilterBar` (passing `inputValue`, `setInputValue`, `onClearSearch`) above the two columns
- Renders two-column layout: `ScriptTemplateList` (left, passing `inputValue` + `onClearSearch`) and `ScriptGeneratorPanel` (right)
- Minimal logic — only the search state lift, everything else in the store or child components
**`ScriptFilterBar`**
- Renders an "All" tab first (calls `setCategory(null)`, active when `activeCategoryId === null`), followed by one tab per category
- Category tabs: pill style, `bg-primary/10` + left 3px cyan accent bar on active tab
- Receives `inputValue: string` and `setInputValue: (v: string) => void` as props from `ScriptLibraryPage` (state is lifted — `ScriptFilterBar` does NOT own local search state)
- `<Input>` is controlled by `inputValue` prop. `useEffect` inside `ScriptFilterBar` watches `inputValue`, schedules `setTimeout(() => setSearch(inputValue), 300)`, clears timeout on cleanup — debounce lives here but the value lives in the page
- Reads `categories`, `activeCategoryId` from store
**`ScriptTemplateList`**
- Scrollable list of `TemplateCard` components
- Reads `templates`, `isLoadingTemplates`, `selectedTemplate` from store
- Accepts `inputValue: string` and `onClearSearch: () => void` props from `ScriptLibraryPage`
- Shows 3 skeleton placeholder cards while `isLoadingTemplates` is true
- Shows "No templates found" empty state when `templates.length === 0` and `!isLoadingTemplates` and `inputValue === ''`
- Shows "No templates match your search" + "Clear search" button when `templates.length === 0` and `!isLoadingTemplates` and `inputValue !== ''`. "Clear search" calls `onClearSearch()` — a callback prop from `ScriptLibraryPage` defined as `() => { setInputValue(''); store.setSearch(''); }`. Using `inputValue` (not `store.searchQuery`) avoids the 300ms debounce lag in empty-state detection
**`TemplateCard`**
- Displays: name, `complexity` badge, `usage_count`, description (2-line clamp via `line-clamp-2`), `tags`
- Shows a `requires_elevation` warning icon (Lucide `ShieldAlert`, amber-400) if true
- Active state when `template.id === selectedTemplate?.id`: `bg-primary/10` background + 3px left cyan gradient accent bar
- Calls `selectTemplate(template.id)` on click
- Complexity badge colors: beginner → emerald-400, intermediate → amber-400, advanced → rose-500
- Slug is already URL/filesystem-safe by backend convention — no sanitization needed
**`ScriptGeneratorPanel`**
- Shows placeholder (Terminal icon + "Select a template to get started") when `selectedTemplate` is null
- Shows a full-panel centered spinner when `isLoadingDetail` is true, replacing the panel content (not an overlay — no prior template content shown while loading). The template list column remains fully visible and interactive
- When template selected and `!isLoadingDetail`: renders template name/description header, `ScriptParameterForm`, `ScriptPreview`, action bar (in that order, top to bottom)
- Visual order within the panel (top to bottom): warnings callout → `ScriptPreview` → action bar → error text
- `generationWarnings` shown as amber-400 callout above the preview when `generationWarnings.length > 0`
- Action bar (below preview):
- Generate button (`bg-gradient-brand`) — calls `generate()` with no arguments (Phase 3 will pass `sessionId`); disabled when `isGenerating` OR `!canGenerate`; shows spinner while `isGenerating`
- Download `.ps1` button — disabled when `generatedScript` is null OR `!canGenerate`; on click triggers `new Blob([generatedScript], { type: 'text/plain' })` → programmatic anchor click with `download="${selectedTemplate.slug}.ps1"`
- `generateError` shown as rose-500 text inline below action bar
- Permission check: call `usePermissions()` directly in this component; derive `canGenerate = isEngineer`. Pass `canGenerate` as a prop down to `ScriptParameterForm`. Generate and Download buttons disabled with tooltip "Engineer access required" when `!canGenerate`
**`ScriptParameterForm`**
- Accepts `canGenerate: boolean` prop from `ScriptGeneratorPanel`
- Iterates `selectedTemplate.parameters_schema.parameters` sorted by `order`. Access via cast: `(selectedTemplate.parameters_schema as ScriptParametersSchema).parameters``parameters_schema` arrives as `dict` at runtime (backend types it as `dict`; helper comment in `ScriptTemplateDetail` type definition)
- Groups by `group` field: renders a `font-label uppercase text-muted-foreground` section label before each group boundary (parameters with `group: null` rendered ungrouped at the top)
- Renders a `ScriptParameterField` per parameter, passing `disabled={!canGenerate}` (converts `canGenerate` boolean to the `disabled` prop expected by `ScriptParameterField`)
- Does NOT own the Generate button and does NOT call `generate()` or `validate()` directly — validation is triggered from `ScriptGeneratorPanel` via the store's `generate()` action (which calls `validate()` internally)
**`ScriptParameterField`**
- Accepts `param: ScriptParameter`, `value: string`, `error: string | undefined`, `disabled: boolean`
- Renders input by `type`. Two error rendering approaches depending on type:
- **Shared error rendering** — pass `error` prop to the shared component; it renders its own error message (do NOT add a separate `<p>` below):
- `text``<Input error={error} />`
- `password``<Input type="password" error={error} />` with Lucide `Eye`/`EyeOff` toggle
- `textarea``<Textarea error={error} />`
- `number``<Input type="number" error={error} />`
- `multi_text``<Input placeholder="Comma-separated values" error={error} />`; stored as single string, backend splits on comma
- **Manual error rendering** — no shared component; render error explicitly as `<p className="mt-1.5 text-xs text-red-400">{error}</p>` below the input:
- `select` → native `<select 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={disabled}>`; options from `parameter.options`
- `boolean``<input type="checkbox" className="rounded border-border" checked={value === 'true'} onChange={e => setParamValue(key, e.target.checked ? 'true' : 'false')} disabled={disabled} />`
- Shows `help_text` as `<p className="text-xs text-muted-foreground mt-1">` below the input (before the error)
- Calls `setParamValue(key, value)` on every change event; boolean uses `e.target.checked ? 'true' : 'false'`
**`ScriptPreview`**
- Two modes determined by `generatedScript` in store:
- **Draft mode** (`generatedScript === null`): client-side `{{key}}` substitution in `selectedTemplate.script_body`. Sensitive params (`parameter.sensitive === true`) rendered as exactly four asterisks `****` regardless of `paramValues` value. Unfilled non-sensitive params left as `{{key}}` for `PowerShellHighlighter` to color amber
- **Generated mode** (`generatedScript !== null`): shows `generatedScript`
- The component computes `displayScript` as a local variable (the substituted preview string in draft mode, or `generatedScript` in generated mode). Both the render and the copy handler reference this same local variable — no separate store field needed
- Copy icon (Lucide `Copy`, 14px) in top-right corner of code block, visible in both modes:
- On click: calls `navigator.clipboard.writeText(displayScript)`. On success: shows "Copied!" for 2s then resets. On failure: silently fails — no error displayed, icon does not change state
- Passes `displayScript` to `PowerShellHighlighter`
**`PowerShellHighlighter`**
- Pure component: `({ script: string }) => JSX.Element`
- Single-pass tokenizer using a combined alternation regex (not sequential replace). The regex alternation matches tokens in priority order; each match is replaced with a `<span>` and the remaining string continues from after the match. This prevents a variable inside a string literal from being re-colored by the variable rule:
```text
Priority order in alternation:
1. Comments: /#[^\r\n]*/
2. String literals: /"[^"]*"|'[^']*'/
3. Unfilled placeholders: /\{\{[^}]+\}\}/
4. Variables: /\$\w+/
5. Cmdlets: /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
6. Parameters: /-[A-Za-z]+/
7. Keywords: /\b(if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/
```
Note: variables (priority 4) consume tokens like `$foreach` before keywords (priority 7) can match — this is intentional. Do not reorder the alternation without reviewing this interaction.
Token colors:
- Comments → `text-[#8b949e]`
- String literals → `text-[#a5d6ff]`
- Unfilled placeholders → `text-amber-400 underline decoration-dashed`
- Variables → `text-[#79c0ff]`
- Cmdlets → `text-[#22d3ee]`
- Parameters → `text-[#d2a8ff]`
- Keywords → `text-[#ff7b72]`
- Unmatched text → unstyled
- Renders as `<pre className="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"><code>`
---
## Routing & Navigation
- Route: `/scripts` added to `frontend/src/router.tsx` inside the `ProtectedRoute`/`AppLayout` children
- Sidebar nav entry: "Scripts" with a `Terminal` icon (Lucide), added to `AppLayout.tsx` in the main nav group
- No sub-routes needed for Phase 2
---
## Permissions
| Action | Minimum role |
| ------ | ------------ |
| View Script Library page | Any authenticated user |
| Browse templates, see draft preview | Any authenticated user |
| Fill form, generate, copy, download | Engineer, owner, or super_admin |
`usePermissions()` is called once in `ScriptGeneratorPanel`. Derive `canGenerate = isEngineer` (`usePermissions().isEngineer` returns `true` for engineer, owner, and super_admin via the role hierarchy check — equivalent to `!isViewer` given the four-role system, but `isEngineer` is more semantically explicit). Pass `canGenerate` as a prop down to `ScriptParameterForm`. Leaf components (`ScriptParameterField`) receive `disabled` as a prop — they do not call `usePermissions()` directly.
Viewer-blocking is frontend-only in Phase 2 (backend role guard deferred — known limitation, documented in Architecture section).
**Tooltip implementation:** Use the HTML `title` attribute on the wrapper `<span>` around disabled buttons — consistent with the existing codebase pattern (e.g. `TopBar.tsx`, `NavItem.tsx`, `CheckoutButton.tsx`). Example: `<span title="Engineer access required"><button disabled ...>Generate</button></span>`.
---
## Search Behaviour
- Search query sent as `?search=` to `GET /scripts/templates`; matches `name`, `description`, `slug` (not tags)
- 300ms debounce in `ScriptFilterBar`: local `inputValue` state, `useEffect` + `setTimeout`/`clearTimeout`
- `setSearch` is called once after the debounce delay; it immediately calls `loadTemplates()`
- Category filter and search compose: `loadTemplates()` sends both `category_slug` and `search` simultaneously
- `inputValue` is owned by `ScriptLibraryPage` (lifted state). `ScriptLibraryPage` passes it as a prop to `ScriptFilterBar` (which controls the `<Input>`) and to `ScriptTemplateList` (which uses it to choose the correct empty-state variant). `onClearSearch` — also defined in `ScriptLibraryPage` as `() => { setInputValue(''); store.setSearch(''); }` — is passed to `ScriptTemplateList` for the "Clear search" button
---
## Empty & Loading States
| Scenario | Treatment |
| -------- | --------- |
| Templates loading | 3 skeleton TemplateCard placeholders (`isLoadingTemplates`) |
| No templates in category | Empty state icon + "No templates found" |
| No search results | "No templates match your search" + "Clear search" button |
| Detail fetch in progress | Spinner in `ScriptGeneratorPanel` (`isLoadingDetail`); template list stays visible |
| No template selected | Right panel: Terminal icon + "Select a template to get started" |
| Generating | Generate button spinner + disabled (`isGenerating`) |
| Generate error | Rose-500 inline text below action bar |
| Warnings after generate | Amber-400 callout above preview, one line per warning |
---
## Out of Scope (Phase 2)
- Session-embedded script generation (Script Output Node) — Phase 3
- Tag filter UI — deferred (backend capability exists via `?tags=`)
- Backend engineer role guard on generate endpoint — deferred (known gap)
- Template creation/editing UI — admin-only, deferred
- Generation history page — deferred
- Admin template management — deferred
- Script execution / RMM integration — long-term roadmap

View File

@@ -0,0 +1,239 @@
# Script Library — Left Pane Takeover Design Spec
> **Created:** 2026-03-13
> **Feature:** Redesign Script Library left pane to have two distinct modes: Browse and Configure
---
## Overview
The Script Library left pane gains two distinct modes. In **Browse mode** the user sees the full template list with filter bar and a "Configure →" button on each card. Clicking "Configure →" transitions the entire left pane (including the filter bar) to **Configure mode**, which replaces the list with a full-height view of the selected template's header, parameter form, and action buttons (Generate, Download, Copy). The right pane becomes a **read-only preview** (`ScriptPreview` only — no form or buttons). "Back to library" returns to Browse mode with filter/search state preserved.
---
## Structural Change Summary
This is a cross-column relocation of the Generate/Download/Copy controls:
| Before | After |
|--------|-------|
| Left pane: template list + filter bar | Left pane: Browse mode (list + filter) OR Configure mode (form + actions) |
| Right pane: param form + action buttons + preview | Right pane: `ScriptPreview` only (read-only display) |
`ScriptGeneratorPanel` (which currently owns the right column's form, actions, and preview) is **deleted**. The right pane becomes `ScriptPreview` in isolation. The new `ScriptConfigurePane` component owns the left pane in Configure mode and contains the form + all action buttons.
---
## Goals
- Make template selection intentional (no accidental click-to-configure)
- Give the parameter form more vertical space by using the full left pane height
- Keep the output preview always visible on the right
- Preserve filter/search state across Browse ↔ Configure transitions
---
## Non-Goals
- No changes to `ScriptPreview` internals — it moves to the right pane as-is, including its existing copy overlay button
- No changes to the Zustand store shape or actions
- No changes to the filter/search debounce logic
- No changes to routing
---
## New Work (not pre-existing)
The **Copy button in the action bar** is new — it does not exist in the current `ScriptGeneratorPanel`. It copies `generatedScript` from the store. It is **disabled** when `generatedScript === null` (i.e., before the user has clicked Generate). The draft preview's copy needs are handled by `ScriptPreview`'s existing overlay copy button. No draft-substitution logic needs to be duplicated in `ScriptConfigurePane`.
---
## Left Pane — Two Modes
### Browse Mode
Rendered when `paneMode === 'browse'`.
The page header (`h1` "Script Library" + subtitle) stays at the top of `ScriptLibraryPage` above the two-column grid, visible in both pane modes — it is not affected by this change.
The current `ScriptLibraryPage` renders `ScriptFilterBar` at the page level above the two-column grid. In this redesign, the filter bar moves **inside the left pane column** so it can be hidden in Configure mode. `inputValue`/`setInputValue` remain owned by `ScriptLibraryPage` (not inside the left pane sub-tree) so the search text survives the unmount when the pane switches to Configure mode.
Layout (top to bottom, fills left pane height):
1. **Filter bar** (`ScriptFilterBar`) — category pills + search input
2. **Template list** (`ScriptTemplateList`) — scrollable, fills remaining height
**TemplateCard changes:**
- Root element changes from `<button>` to `<div>` — the card itself is no longer clickable
- Remove active/selected visual state (`bg-primary/10 border-l-[3px]` etc.) — no card is "selected" in browse mode
- Add **"Configure →"** button, right-aligned in the bottom row
- Style: `bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors`
- Uses unicode `→` (not a Lucide icon)
- On click: calls `onConfigure(template.id)` prop
- The rest of the card (name, description, tags, complexity badge) is non-interactive
**Updated `TemplateCard` props:**
```tsx
interface Props {
template: ScriptTemplateListItem
onConfigure: (id: string) => void
}
```
`TemplateCard` also removes its `useScriptGeneratorStore` import entirely — it no longer reads `selectedTemplate` or `selectTemplate` from the store.
### Configure Mode
Rendered when `paneMode === 'configure'`.
The entire left pane (including filter bar) is replaced by the Configure view.
Layout (top to bottom, full pane height, `overflow-y-auto`):
1. **Back button**
- Label: `← Back to library`
- Style: `flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4`
- On click: calls `onBack` prop → `ScriptLibraryPage` calls `store.clearOutput()` then `setPaneMode('browse')`
- Does NOT clear `selectedTemplate` in the store (no such action exists — `selectTemplate` only accepts a string ID). `selectedTemplate` remains set in the store after returning to Browse mode. The right pane (`ScriptPreview`) continues showing the last preview while browsing — this is intentional.
2. **Template header** (visible when `isLoadingDetail === false`)
- Name: `text-base font-semibold font-heading text-foreground`
- Description (if present): `text-sm text-muted-foreground mt-0.5`
- Tag row (left-to-right): `ShieldAlert` icon if `requires_elevation`, complexity badge, category name (resolved from `store.categories.find(c => c.id === selectedTemplate.category_id)?.name`), template tags (first 3, overflow as `+N`) — same badge/tag styles as current `TemplateCard`. If no matching category is found, omit the category name chip.
- Separator: `border-t border-border mt-3 pt-3`
3. **`ScriptParameterForm`** — existing component, `canGenerate` prop unchanged
4. **Warnings callout** — shown above Generate when `generationWarnings.length > 0` (same amber box as current `ScriptGeneratorPanel`)
5. **Action bar**
- **Generate** button: full-width, `bg-gradient-brand`, disabled/loading behavior same as current
- **Download .ps1** + **Copy** buttons: side by side below Generate, each `flex-1`
- The Copy button copies `store.generatedScript`. It is disabled when `generatedScript === null`. Draft copy is handled by `ScriptPreview`'s existing overlay — two copy entry-points is acceptable.
- Error text below if `generateError` is set
**Loading state:** When `isLoadingDetail === true`, shows a centered `<Loader2 size={28} className="text-primary animate-spin" />` filling the pane instead of the template content.
---
## Right Pane
After the redesign, the right pane contains **only `ScriptPreview`**, wrapped in a `glass-card-static h-full` container.
Two sub-states:
- **No template ever selected** (`selectedTemplate === null`): show empty state — Terminal icon + "Select a template to get started" text. **Important:** `ScriptPreview` returns `null` when `selectedTemplate` is null, so the empty state must be rendered by the right-pane wrapper in `ScriptLibraryPage`, not by `ScriptPreview` itself. Pattern:
```tsx
{selectedTemplate === null ? (
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Terminal size={40} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Select a template to get started</p>
</div>
) : (
<div className="glass-card-static h-full overflow-hidden p-4">
<ScriptPreview />
</div>
)}
```
- **Template selected** (in either pane mode): show `ScriptPreview`
The right pane is always read-only — no form, no Generate/Download buttons.
**Layout note:** `ScriptPreview` renders a `<div className="relative">` at its root; the copy overlay button is `position: absolute, top-3, right-3` inside it. The right-pane wrapper must **not** use `overflow-y-auto` — if it did, the absolute copy button would scroll out of view on long scripts. Instead, the wrapper is `overflow-hidden` and `ScriptPreview`'s inner `<pre>` (inside `PowerShellHighlighter`) provides its own `overflow-x-auto` for wide scripts. The right pane itself does not need to scroll vertically — `PowerShellHighlighter` already handles horizontal overflow. No changes to `ScriptPreview` are needed.
The correct right-pane wrapper pattern:
```tsx
<div className="glass-card-static h-full overflow-hidden p-4">
<ScriptPreview />
</div>
```
**Right pane during initial detail load:** When the user clicks "Configure →" for the first time (`selectedTemplate === null`, `isLoadingDetail === true`), the right pane still shows the empty state (Terminal icon). The left pane's configure view shows the loading spinner. This is acceptable — the right pane updating when `isLoadingDetail` resolves is sufficient. No right-pane loading state is needed.
---
## State — Pane Mode
Pane mode is **local React state** in `ScriptLibraryPage`, not the Zustand store.
```tsx
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
```
Transitions:
- `'browse' → 'configure'`: `onConfigure(id)` — calls `store.selectTemplate(id)` then `setPaneMode('configure')`
- `'configure' → 'browse'`: `onBack()` — calls `store.clearOutput()` then `setPaneMode('browse')`
**`isLoadingDetail` drives configure pane content, not pane mode.** When the user clicks "Configure →":
1. `selectTemplate(id)` is called (sets `isLoadingDetail: true` in store)
2. `setPaneMode('configure')` is called immediately — user sees the loading spinner in configure mode
**`selectTemplate` failure case:** If `selectTemplate` throws (network error), the store resets `isLoadingDetail: false` but leaves `selectedTemplate` unchanged. The pane mode remains `'configure'`.
- **First-selection failure** (`selectedTemplate === null` before the call): `ScriptConfigurePane` must handle `!isLoadingDetail && !selectedTemplate` — render an error state: "Failed to load template." with a "← Back to library" link.
- **Subsequent-selection failure** (`selectedTemplate` is still set from the previous template): the configure pane silently shows the previous template's form with stale data. This is an accepted edge case — network errors mid-session are rare and the user can press Back to recover. No special handling required.
**`paramValues` and `formErrors` on Back:** `clearOutput()` does not reset `paramValues` or `formErrors`. This is intentional — if the user returns to browse mode and then clicks "Configure →" on the same template again, `selectTemplate` will repopulate `paramValues` from defaults, discarding any edits. If they configure a different template, `selectTemplate` again repopulates from that template's defaults. There is no scenario where stale param values from a prior template persist into a new template's form. No additional cleanup is needed on Back.
**Filter/search preservation:** `inputValue` remains owned by `ScriptLibraryPage` at the page level. The filter bar unmounts in configure mode and remounts in browse mode with the same `inputValue`. Store's `activeCategoryId` and `searchQuery` are never cleared by pane transitions.
---
## Component Changes
| File | Change |
|------|--------|
| `frontend/src/pages/ScriptLibraryPage.tsx` | Add `paneMode` state; add `usePermissions` import for `canGenerate`; move `ScriptFilterBar` into left pane column; add `onConfigure`/`onBack` callbacks; render `ScriptConfigurePane` or browse content in left pane conditionally; render right pane as `ScriptPreview`-only with empty state |
| `frontend/src/components/scripts/TemplateCard.tsx` | Root `<button>` → `<div>`; remove `onClick`/active-state; add `onConfigure: (id: string) => void` prop; add "Configure →" button |
| `frontend/src/components/scripts/ScriptTemplateList.tsx` | Accept `onConfigure: (id: string) => void` prop; pass to each `TemplateCard`. `inputValue: string` and `onClearSearch: () => void` props are unchanged. |
| `frontend/src/components/scripts/ScriptConfigurePane.tsx` | **New** — configure mode layout (back button, template header, `ScriptParameterForm`, warnings, action bar with Generate + Download + Copy) |
| `frontend/src/components/scripts/ScriptGeneratorPanel.tsx` | **Delete** — superseded by `ScriptConfigurePane` and right-pane simplification |
No store changes. No API changes. No routing changes.
---
## Visual Spec
### Page layout (Configure mode active)
```
┌─ left pane (320px) ────────────┬─ right pane (1fr) ──────────────────┐
│ ← Back to library │ │
│ │ [ScriptPreview — always visible] │
│ Restart Windows Service │ │
│ Stops and restarts a service │ # Restart Windows Service │
│ 🛡 [Beginner] [Services] [win] │ param( │
│ ───────────────────────────── │ $ServiceName = "{{service_name}}" │
│ Service Name * │ ) [copy overlay] │
│ [________________] │ │
│ Target Computer │ │
│ [localhost______] │ │
│ Verify after restart [✓] │ │
│ │ │
│ [ Generate Script ] │ │
│ [ Download .ps1 ] [ Copy ] │ │
└────────────────────────────────┴──────────────────────────────────────┘
```
### TemplateCard — Browse mode
```
┌──────────────────────────────────────────────────────┐
│ Restart Windows Service 🛡 [Beginner] │
│ Stops and restarts a named service │
│ 4× used [services] [windows] +1 [Configure →] │
└──────────────────────────────────────────────────────┘
```
### `ScriptConfigurePane` props
```tsx
interface Props {
canGenerate: boolean
onBack: () => void
}
```
All other data read from Zustand store directly. `ScriptConfigurePane` derives `canGenerate` from props only — it does NOT call `usePermissions` internally. `ScriptLibraryPage` is the sole caller of `usePermissions` for this feature.
**Download filename:** `${selectedTemplate.slug}.ps1` — consistent with the current `ScriptGeneratorPanel` behavior.

View File

@@ -21,3 +21,4 @@ export { copilotApi } from './copilot'
export { assistantChatApi } from './assistantChat'
export { flowTransferApi } from './flowTransfer'
export { kbAcceleratorApi } from './kbAccelerator'
export { scriptsApi } from './scripts'

View File

@@ -0,0 +1,78 @@
import apiClient from './client'
import type {
ScriptCategoryResponse,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptGenerateRequest,
ScriptGenerateResponse,
ScriptGenerationRecord,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
} from '@/types'
export const scriptsApi = {
async getCategories(): Promise<ScriptCategoryResponse[]> {
const response = await apiClient.get<ScriptCategoryResponse[]>('/scripts/categories')
return response.data
},
async getTemplates(params?: {
category_slug?: string
search?: string
tags?: string // Phase 3: comma-separated tag filter
}): Promise<ScriptTemplateListItem[]> {
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
return response.data
},
async getTemplateDetail(id: string): Promise<ScriptTemplateDetail> {
const response = await apiClient.get<ScriptTemplateDetail>(`/scripts/templates/${id}`)
return response.data
},
async generate(req: ScriptGenerateRequest): Promise<ScriptGenerateResponse> {
const response = await apiClient.post<ScriptGenerateResponse>('/scripts/generate', req)
return response.data
},
// Phase 3: fetch generation history for the current user
async getGenerations(): Promise<ScriptGenerationRecord[]> {
const response = await apiClient.get<ScriptGenerationRecord[]>('/scripts/generations')
return response.data
},
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
},
}
export default scriptsApi

View File

@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/react'
import { type ReactNode } from 'react'
import { type ReactNode, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/Button'
interface FallbackProps {
@@ -18,17 +18,20 @@ function isChunkLoadError(error: Error): boolean {
}
function DefaultFallback({ error, resetError }: FallbackProps) {
const reloadingRef = useRef(false)
// Auto-reload on stale chunk errors (happens after deployments)
if (isChunkLoadError(error)) {
useEffect(() => {
if (!isChunkLoadError(error)) return
const key = 'rf_boundary_chunk_reload'
const lastReload = sessionStorage.getItem(key)
const now = Date.now()
if (!lastReload || now - Number(lastReload) > 10_000) {
sessionStorage.setItem(key, String(now))
reloadingRef.current = true
window.location.reload()
return null
}
}
}, [error])
return (
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useLocation, useNavigate, Link } from 'react-router-dom'
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield, Terminal } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -57,6 +57,7 @@ export function AppLayout() {
{ path: '/sessions', label: 'Sessions', icon: Clock },
{ path: '/shares', label: 'Exports', icon: FileText },
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
{ path: '/scripts', label: 'Script Library', icon: Terminal },
{ path: '/account', label: 'Account', icon: Settings },
]

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles } from 'lucide-react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen, Sparkles, Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
@@ -83,6 +83,7 @@ export function Sidebar() {
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/scripts" icon={Terminal} label="Script Library" collapsed />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
@@ -116,6 +117,7 @@ export function Sidebar() {
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/scripts" icon={Terminal} label="Script Library" />
<NavItem href="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
</div>

View File

@@ -0,0 +1,317 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, GripVertical, Trash2, Plus, X } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import type { ScriptParameter, ScriptParameterOption, ScriptParameterValidation } from '@/types'
const PARAM_TYPES = [
{ value: 'text', label: 'Text' },
{ value: 'password', label: 'Password' },
{ value: 'textarea', label: 'Textarea' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'select', label: 'Select' },
{ value: 'multi_text', label: 'Multi-text' },
] as const
interface Props {
param: ScriptParameter
index: number
onChange: (index: number, updated: ScriptParameter) => void
onRemove: (index: number) => void
onMoveUp: (index: number) => void
onMoveDown: (index: number) => void
isFirst: boolean
isLast: boolean
disabled?: boolean
}
export function ParameterCard({
param, index, onChange, onRemove, onMoveUp, onMoveDown, isFirst, isLast, disabled,
}: Props) {
const [expanded, setExpanded] = useState(true)
const update = (patch: Partial<ScriptParameter>) => {
onChange(index, { ...param, ...patch })
}
const updateOption = (optIndex: number, patch: Partial<ScriptParameterOption>) => {
const options = [...(param.options ?? [])]
options[optIndex] = { ...options[optIndex], ...patch }
update({ options })
}
const addOption = () => {
const options = [...(param.options ?? []), { value: '', label: '' }]
update({ options })
}
const removeOption = (optIndex: number) => {
const options = (param.options ?? []).filter((_, i) => i !== optIndex)
update({ options })
}
const updateValidation = (patch: Partial<ScriptParameterValidation>) => {
update({ validation: { ...(param.validation ?? {}), ...patch } })
}
return (
<div className="border border-border rounded-xl overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
>
<GripVertical size={14} className="text-muted-foreground/50 shrink-0" />
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
<span className="text-sm font-medium text-foreground flex-1 text-left">
{param.label || param.key || `Parameter ${index + 1}`}
</span>
<span className="font-label text-[0.625rem] text-muted-foreground uppercase">{param.type}</span>
{param.required && <span className="text-red-400 text-xs">*</span>}
</button>
{/* Body */}
{expanded && (
<div className="px-3 py-3 space-y-3 border-t border-border">
{/* Row 1: key + label */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key (used in &#123;&#123;key&#125;&#125;)</label>
<Input
value={param.key}
onChange={e => update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
placeholder="param_key"
disabled={disabled}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={param.label}
onChange={e => update({ label: e.target.value })}
placeholder="Display Label"
disabled={disabled}
/>
</div>
</div>
{/* Row 2: type + group */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Type</label>
<select
value={param.type}
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
disabled={disabled}
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Group (optional)</label>
<Input
value={param.group ?? ''}
onChange={e => update({ group: e.target.value || null })}
placeholder="e.g. User Identity"
disabled={disabled}
/>
</div>
</div>
{/* Row 3: placeholder + help text */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Placeholder</label>
<Input
value={param.placeholder ?? ''}
onChange={e => update({ placeholder: e.target.value || null })}
placeholder="Placeholder text"
disabled={disabled}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Help text</label>
<Input
value={param.help_text ?? ''}
onChange={e => update({ help_text: e.target.value || null })}
placeholder="Help text shown below field"
disabled={disabled}
/>
</div>
</div>
{/* Row 4: toggles */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={param.required}
onChange={e => update({ required: e.target.checked })}
disabled={disabled}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={param.sensitive}
onChange={e => update({ sensitive: e.target.checked })}
disabled={disabled}
className="rounded border-border"
/>
Sensitive (redacted in logs)
</label>
</div>
{/* Default value */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={param.default !== null && param.default !== undefined ? String(param.default) : ''}
onChange={e => update({ default: e.target.value || null })}
placeholder="Default value"
disabled={disabled}
/>
</div>
{/* Select options (only for select type) */}
{param.type === 'select' && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Options</label>
<div className="space-y-1.5">
{(param.options ?? []).map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={opt.value}
onChange={e => updateOption(i, { value: e.target.value })}
placeholder="value"
disabled={disabled}
/>
<Input
value={opt.label}
onChange={e => updateOption(i, { label: e.target.value })}
placeholder="label"
disabled={disabled}
/>
<button
type="button"
onClick={() => removeOption(i)}
disabled={disabled}
className="p-1 text-muted-foreground hover:text-rose-500 transition-colors"
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addOption}
disabled={disabled}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<Plus size={12} /> Add option
</button>
</div>
</div>
)}
{/* Validation (for text/number types) */}
{(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">Validation (optional)</label>
<div className="grid grid-cols-3 gap-2">
{param.type === 'number' ? (
<>
<div>
<label className="text-[0.625rem] text-muted-foreground">Min value</label>
<Input
type="number"
value={param.validation?.min_value ?? ''}
onChange={e => updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
<div>
<label className="text-[0.625rem] text-muted-foreground">Max value</label>
<Input
type="number"
value={param.validation?.max_value ?? ''}
onChange={e => updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
</>
) : (
<>
<div>
<label className="text-[0.625rem] text-muted-foreground">Min length</label>
<Input
type="number"
value={param.validation?.min_length ?? ''}
onChange={e => updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
<div>
<label className="text-[0.625rem] text-muted-foreground">Max length</label>
<Input
type="number"
value={param.validation?.max_length ?? ''}
onChange={e => updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })}
disabled={disabled}
/>
</div>
</>
)}
<div>
<label className="text-[0.625rem] text-muted-foreground">Pattern (regex)</label>
<Input
value={param.validation?.pattern ?? ''}
onChange={e => updateValidation({ pattern: e.target.value || undefined })}
placeholder="^[a-z]+$"
disabled={disabled}
/>
</div>
</div>
</div>
)}
{/* Actions row */}
<div className="flex items-center justify-between pt-1 border-t border-border">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onMoveUp(index)}
disabled={isFirst || disabled}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
>
Up
</button>
<button
type="button"
onClick={() => onMoveDown(index)}
disabled={isLast || disabled}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
>
Down
</button>
</div>
<button
type="button"
onClick={() => onRemove(index)}
disabled={disabled}
className="flex items-center gap-1 text-xs text-rose-500 hover:text-rose-400 transition-colors px-1.5 py-0.5"
>
<Trash2 size={12} /> Remove
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { useState } from 'react'
import { ChevronRight, SkipForward, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import type { ParameterCandidate, ScriptParameter } from '@/types'
const PARAM_TYPES: { value: ScriptParameter['type']; label: string }[] = [
{ 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' },
]
interface Props {
candidates: ParameterCandidate[]
existingKeys: string[]
onAccept: (candidate: ParameterCandidate, overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}) => void
onSkip: (candidate: ParameterCandidate) => void
onFinish: (acceptedCount: number, totalCount: number) => void
}
export function ParameterDetectorStepper({
candidates,
existingKeys,
onAccept,
onSkip,
onFinish,
}: Props) {
const [currentIndex, setCurrentIndex] = useState(0)
const [acceptedCount, setAcceptedCount] = useState(0)
const [showInferenceInfo, setShowInferenceInfo] = useState(false)
const current = candidates[currentIndex]
const [key, setKey] = useState(current.suggestedKey)
const [label, setLabel] = useState(current.suggestedLabel)
const [type, setType] = useState<ScriptParameter['type']>(current.suggestedType)
const [sensitive, setSensitive] = useState(current.sensitive)
const [required, setRequired] = useState(true)
const [defaultValue, setDefaultValue] = useState(
current.defaultValue !== null ? String(current.defaultValue) : ''
)
const isLast = currentIndex === candidates.length - 1
const resetFieldsForIndex = (index: number) => {
const c = candidates[index]
setKey(c.suggestedKey)
setLabel(c.suggestedLabel)
setType(c.suggestedType)
setSensitive(c.sensitive)
setRequired(true)
setDefaultValue(c.defaultValue !== null ? String(c.defaultValue) : '')
setShowInferenceInfo(false)
}
const handleAccept = () => {
const parsedDefault = type === 'boolean'
? defaultValue === 'true'
: type === 'number'
? (defaultValue ? Number(defaultValue) : null)
: (defaultValue || null)
onAccept(current, {
key,
label,
type,
sensitive,
required,
defaultValue: parsedDefault,
})
const newAccepted = acceptedCount + 1
setAcceptedCount(newAccepted)
if (isLast) {
onFinish(newAccepted, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
const handleSkip = () => {
onSkip(current)
if (isLast) {
onFinish(acceptedCount, candidates.length)
} else {
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
resetFieldsForIndex(nextIndex)
}
}
return (
<div className="border border-primary/20 rounded-xl bg-primary/[0.03] p-4 space-y-3">
{/* Progress */}
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length}
</p>
<div className="flex items-center gap-1">
{candidates.map((_, i) => (
<div
key={i}
className={cn(
'h-1.5 w-1.5 rounded-full transition-colors',
i < currentIndex ? 'bg-primary' :
i === currentIndex ? 'bg-primary animate-pulse' :
'bg-border'
)}
/>
))}
</div>
</div>
{/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-label text-xs text-amber-400 break-all">
{current.matchedLine}
</p>
<p className="font-label text-[0.5rem] text-muted-foreground mt-1">
Line {current.lineNumber} · {current.source === 'param_block' ? 'param() block' : 'variable assignment'}
</p>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">Key</label>
<Input
value={key}
onChange={e => setKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
placeholder="param_key"
/>
{existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
<Input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="Display Label"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 flex items-center gap-1.5">
Type
<button
type="button"
onClick={() => setShowInferenceInfo(!showInferenceInfo)}
className="text-muted-foreground hover:text-primary transition-colors"
title={current.inferenceReason}
>
<Info size={11} />
</button>
</label>
<select
value={type}
onChange={e => setType(e.target.value as ScriptParameter['type'])}
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)]"
>
{PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{showInferenceInfo && (
<p className="text-[0.625rem] text-primary/80 mt-1 italic">
{current.inferenceReason}
</p>
)}
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
<Input
value={defaultValue}
onChange={e => setDefaultValue(e.target.value)}
placeholder="Original value preserved"
/>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={required}
onChange={e => setRequired(e.target.checked)}
className="rounded border-border"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={sensitive}
onChange={e => setSensitive(e.target.checked)}
className="rounded border-border"
/>
Sensitive
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-border">
<button
type="button"
onClick={handleSkip}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-1.5"
>
<SkipForward size={13} />
{isLast ? 'Skip & Finish' : 'Skip'}
</button>
<button
type="button"
onClick={handleAccept}
disabled={!key.trim() || !label.trim()}
className="flex items-center gap-1.5 bg-gradient-brand text-[#101114] font-semibold text-sm px-4 py-1.5 rounded-[10px] hover:opacity-90 active:scale-[0.97] transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLast ? (
<><Check size={13} /> Accept &amp; Finish</>
) : (
<><ChevronRight size={13} /> Accept &amp; Next</>
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,172 @@
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]]
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)])
}
const switchToJson = () => {
setJsonText(JSON.stringify(schema, null, 2))
setJsonError(null)
setMode('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>
)
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react'
import Editor, { type BeforeMount } from '@monaco-editor/react'
import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme'
import { Spinner } from '@/components/common/Spinner'
interface Props {
value: string
onChange: (value: string) => void
disabled?: boolean
}
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
// Register our dark theme if not already defined
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
}, [])
return (
<div className="rounded-xl border border-border overflow-hidden">
<Editor
height="300px"
language="powershell"
theme={THEME_ID}
value={value}
onChange={v => onChange(v ?? '')}
beforeMount={handleBeforeMount}
loading={
<div className="flex h-[300px] items-center justify-center bg-card">
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
</div>
}
options={{
minimap: { enabled: false },
fontSize: 13,
fontFamily: "'JetBrains Mono', monospace",
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
tabSize: 4,
insertSpaces: true,
automaticLayout: true,
readOnly: disabled,
padding: { top: 12, bottom: 12 },
scrollbar: {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
}}
/>
</div>
)
}

View File

@@ -0,0 +1,550 @@
import { useState, useEffect } from 'react'
import { ArrowLeft, Loader2, Save, Scan, 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 { detectParameterCandidates } from '@/lib/scriptParameterDetector'
import { ParameterDetectorStepper } from './ParameterDetectorStepper'
import type {
ScriptTemplateDetail,
ScriptCategoryResponse,
ScriptParametersSchema,
ScriptTemplateCreateRequest,
ScriptTemplateUpdateRequest,
ParameterCandidate,
ScriptParameter,
} 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 [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
const [showStepper, setShowStepper] = useState(false)
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
const { canShareScriptTemplate } = usePermissions()
// Dismiss stepper if user edits the script body during detection
const scriptBodyRef = form.script_body
useEffect(() => {
if (showStepper) {
setShowStepper(false)
setDetectedCandidates([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scriptBodyRef])
// 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()
}
const handleDetectParameters = () => {
const candidates = detectParameterCandidates(form.script_body)
if (candidates.length === 0) {
setDetectionSummary('No parameter candidates detected in the script body.')
setShowStepper(false)
setTimeout(() => setDetectionSummary(null), 4000)
return
}
setDetectedCandidates(candidates)
setDetectionSummary(null)
setShowStepper(true)
}
const handleAcceptCandidate = (
candidate: ParameterCandidate,
overrides: {
key: string
label: string
type: ScriptParameter['type']
sensitive: boolean
required: boolean
defaultValue: string | boolean | number | null
}
) => {
let updatedScript = form.script_body
if (candidate.source === 'param_block') {
const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/)
if (defaultMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`)
)
}
} else {
const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/)
if (assignMatch) {
updatedScript = updatedScript.replace(
candidate.matchedLine,
candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`)
)
}
}
const existingParams = form.parameters_schema.parameters
const newParam: ScriptParameter = {
key: overrides.key,
label: overrides.label,
type: overrides.type,
required: overrides.required,
placeholder: null,
group: null,
order: existingParams.length + 1,
help_text: null,
options: null,
default: overrides.defaultValue,
validation: null,
sensitive: overrides.sensitive,
}
setForm(f => ({
...f,
script_body: updatedScript,
parameters_schema: {
parameters: [...f.parameters_schema.parameters, newParam],
},
}))
setIsDirty(true)
}
const handleSkipCandidate = () => {
// Nothing to do — stepper advances internally
}
const handleDetectionFinish = (acceptedCount: number, totalCount: number) => {
setShowStepper(false)
setDetectedCandidates([])
setDetectionSummary(
acceptedCount === 0
? 'No parameters were added.'
: `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.`
)
setTimeout(() => setDetectionSummary(null), 5000)
}
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)}
/>
{/* Detect Parameters button + stepper */}
{form.script_body.trim() && !showStepper && (
<button
type="button"
onClick={handleDetectParameters}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] px-3 py-1.5 rounded-[10px] transition-all"
>
<Scan size={14} />
Detect Parameters
</button>
)}
{detectionSummary && (
<p className="text-xs text-muted-foreground italic">{detectionSummary}</p>
)}
{showStepper && detectedCandidates.length > 0 && (
<ParameterDetectorStepper
candidates={detectedCandidates}
existingKeys={form.parameters_schema.parameters.map(p => p.key)}
onAccept={handleAcceptCandidate}
onSkip={handleSkipCandidate}
onFinish={handleDetectionFinish}
/>
)}
</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>
)
}

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { Plus, Search, Pencil, Trash2, Users, User as UserIcon, Loader2, FileCode, ArrowLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
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">
{/* Back link */}
<Link
to="/scripts"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<ArrowLeft size={12} />
Back to Script Library
</Link>
{/* 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>
)
}

View File

@@ -0,0 +1,98 @@
/**
* Single-pass PowerShell syntax highlighter.
*
* Uses a combined alternation regex so tokens matched earlier in the list
* cannot be re-coloured by later rules (e.g. a variable inside a string
* is captured by the string rule and won't be re-matched by the variable rule).
*
* Priority order:
* 1. Comments /#[^\r\n]star/
* 2. String literals /"[^"]*"|'[^']*'/
* 3. Unfilled placeholders /\{\{[^}]+\}\}/
* 4. Variables /\$\w+/
* 5. Cmdlets /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
* 6. Parameters /-[A-Za-z]+/
* 7. Keywords /\b(if|else|...)\b/
*
* Note: variables (priority 4) consume $foreach before keywords (priority 7)
* can match — this is intentional PowerShell behaviour.
*/
import React from 'react'
const TOKEN_REGEX = new RegExp(
[
/#[^\r\n]*/, // 1. comments
/"[^"]*"|'[^']*'/, // 2. string literals
/\{\{[^}]+\}\}/, // 3. unfilled placeholders
/\$\w+/, // 4. variables
/[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun)
/-[A-Za-z]+/, // 6. parameters
/\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords
]
.map(r => r.source)
.join('|'),
'g'
)
const TOKEN_CLASSES: Record<string, string> = {
comment: 'text-[#8b949e]',
string: 'text-[#a5d6ff]',
placeholder: 'text-amber-400 underline decoration-dashed',
variable: 'text-[#79c0ff]',
cmdlet: 'text-[#22d3ee]',
parameter: 'text-[#d2a8ff]',
keyword: 'text-[#ff7b72]',
}
const KEYWORDS = new Set([
'if', 'else', 'elseif', 'foreach', 'for', 'while',
'function', 'return', 'try', 'catch', 'finally', 'param', 'switch',
])
function classify(token: string): string {
if (token.startsWith('#')) return 'comment'
if (token.startsWith('"') || token.startsWith("'")) return 'string'
if (token.startsWith('{{')) return 'placeholder'
if (token.startsWith('$')) return 'variable'
if (/^-[A-Za-z]+$/.test(token)) return 'parameter'
if (KEYWORDS.has(token)) return 'keyword'
return 'cmdlet'
}
interface Props {
script: string
className?: string
}
export function PowerShellHighlighter({ script, className }: Props) {
const parts: React.ReactNode[] = []
let lastIndex = 0
TOKEN_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = TOKEN_REGEX.exec(script)) !== null) {
if (match.index > lastIndex) {
parts.push(script.slice(lastIndex, match.index))
}
const token = match[0]
const kind = classify(token)
parts.push(
<span key={match.index} className={TOKEN_CLASSES[kind]}>
{token}
</span>
)
lastIndex = match.index + token.length
}
if (lastIndex < script.length) {
parts.push(script.slice(lastIndex))
}
return (
<pre className={className ?? "font-label text-sm bg-card rounded-xl p-4 overflow-x-auto"}>
<code>{parts}</code>
</pre>
)
}

View File

@@ -0,0 +1,214 @@
import { useState } from 'react'
import { ArrowLeft, Terminal, Download, Loader2, AlertTriangle, Copy, Check, ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterForm } from './ScriptParameterForm'
const COMPLEXITY_CLASSES = {
beginner: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20',
intermediate: 'text-amber-400 bg-amber-400/10 border-amber-400/20',
advanced: 'text-rose-500 bg-rose-500/10 border-rose-500/20',
} as const
interface Props {
canGenerate: boolean
onBack: () => void
}
export function ScriptConfigurePane({ canGenerate, onBack }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const isLoadingDetail = useScriptGeneratorStore(s => s.isLoadingDetail)
const categories = useScriptGeneratorStore(s => s.categories)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const generationWarnings = useScriptGeneratorStore(s => s.generationWarnings)
const isGenerating = useScriptGeneratorStore(s => s.isGenerating)
const generateError = useScriptGeneratorStore(s => s.generateError)
const generate = useScriptGeneratorStore(s => s.generate)
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
if (!generatedScript) return
try {
await navigator.clipboard.writeText(generatedScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail
}
}
const handleDownload = () => {
if (!generatedScript || !selectedTemplate) return
const blob = new Blob([generatedScript], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedTemplate.slug}.ps1`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Loading state
if (isLoadingDetail) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex items-center justify-center">
<Loader2 size={28} className="text-primary animate-spin" />
</div>
</div>
)
}
// First-selection failure state
if (!selectedTemplate) {
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-center">
<Terminal size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Failed to load template.</p>
</div>
</div>
)
}
const categoryName = categories.find(c => c.id === selectedTemplate.category_id)?.name
const displayTags = selectedTemplate.tags.slice(0, 3)
const extraTagCount = selectedTemplate.tags.length - 3
return (
<div className="glass-card-static h-full flex flex-col p-4 overflow-y-auto">
{/* Back button */}
<button
type="button"
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4 w-fit"
>
<ArrowLeft size={12} />
Back to library
</button>
{/* Template header */}
<div className="mb-3">
<h2 className="text-base font-semibold font-heading text-foreground">
{selectedTemplate.name}
</h2>
{selectedTemplate.description && (
<p className="text-sm text-muted-foreground mt-0.5">{selectedTemplate.description}</p>
)}
<div className="flex items-center gap-1.5 flex-wrap mt-2">
{selectedTemplate.requires_elevation && (
<span
title="Requires administrator elevation"
className="inline-flex items-center gap-1 font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border text-amber-400 bg-amber-400/10 border-amber-400/20"
>
<ShieldAlert size={11} />
Elevated
</span>
)}
<span className={cn(
'font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border',
COMPLEXITY_CLASSES[selectedTemplate.complexity]
)}>
{selectedTemplate.complexity}
</span>
{categoryName && (
<span className="font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{categoryName}
</span>
)}
{displayTags.map(tag => (
<span key={tag} className="font-label text-[0.625rem] px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-white/5">
{tag}
</span>
))}
{extraTagCount > 0 && (
<span className="font-label text-[0.625rem] text-muted-foreground">+{extraTagCount}</span>
)}
</div>
</div>
<div className="border-t border-border mt-3 pt-3" />
{/* Parameter form */}
<ScriptParameterForm canGenerate={canGenerate} />
{/* Warnings */}
{generationWarnings.length > 0 && (
<div className="flex flex-col gap-1 rounded-lg border border-amber-400/20 bg-amber-400/5 p-3 mt-4">
<div className="flex items-center gap-1.5 text-amber-400 text-xs font-medium mb-1">
<AlertTriangle size={13} />
Warnings
</div>
{generationWarnings.map((w) => (
<p key={w} className="text-xs text-amber-400/80">{w}</p>
))}
</div>
)}
{/* Action bar */}
<div className="flex flex-col gap-2 mt-4 pt-1">
<span title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating || !canGenerate}
className="w-full flex items-center justify-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 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
>
{isGenerating && <Loader2 size={14} className="animate-spin" />}
Generate Script
</button>
</span>
<div className="flex gap-2">
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleDownload}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} />
Download .ps1
</button>
</span>
<span className="flex-1" title={!canGenerate ? 'Engineer access required' : undefined}>
<button
type="button"
onClick={handleCopy}
disabled={!generatedScript || !canGenerate}
className="w-full flex items-center justify-center gap-1.5 bg-white/5 border border-border text-foreground text-sm px-4 py-2 rounded-[10px] hover:border-[rgba(255,255,255,0.12)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
</span>
</div>
</div>
{/* Generate error */}
{generateError && (
<p className="text-xs text-rose-500 mt-2">{generateError}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/Input'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
interface Props {
inputValue: string
setInputValue: (value: string) => void
}
export function ScriptFilterBar({ inputValue, setInputValue }: Props) {
const categories = useScriptGeneratorStore(s => s.categories)
const activeCategoryId = useScriptGeneratorStore(s => s.activeCategoryId)
const setCategory = useScriptGeneratorStore(s => s.setCategory)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
// Debounce: 300ms after the input value settles, push to store.
// Skip on initial mount (store.searchQuery is already '' and page already called loadTemplates).
const isFirstRender = useRef(true)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
setSearch(inputValue)
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [inputValue, setSearch])
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Category pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setCategory(null)}
className={cn(
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
activeCategoryId === null
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
: 'border-border text-muted-foreground hover:border-[rgba(255,255,255,0.12)] hover:text-foreground'
)}
>
All
</button>
{categories.map(cat => (
<button
key={cat.id}
type="button"
onClick={() => setCategory(cat.id)}
className={cn(
'font-label text-xs px-3 py-1.5 rounded-full border transition-all',
activeCategoryId === cat.id
? 'bg-primary/10 border-primary/30 text-foreground border-l-[3px] border-l-primary'
: 'border-border text-muted-foreground hover:border-[rgba(255,255,255,0.12)] hover:text-foreground'
)}
>
{cat.name}
</button>
))}
</div>
{/* Search input */}
<div className="relative ml-auto">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none z-10" />
<Input
type="text"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="Search templates..."
className="pl-8 w-52"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Textarea } from '@/components/ui/Textarea'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import type { ScriptParameter } from '@/types'
interface Props {
param: ScriptParameter
value: string
error: string | undefined
disabled: boolean
}
export function ScriptParameterField({ param, value, error, disabled }: Props) {
const setParamValue = useScriptGeneratorStore(s => s.setParamValue)
const [showPassword, setShowPassword] = useState(false)
const id = `param-${param.key}`
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setParamValue(param.key, e.target.value)
}
const handleCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
setParamValue(param.key, e.target.checked ? 'true' : 'false')
}
let input: React.ReactNode
// Track whether the shared Input/Textarea component renders the error internally
// (so we skip the manual <p> at the bottom for these types)
let errorRenderedByComponent = false
if (param.type === 'text' || param.type === 'multi_text' || param.type === 'number') {
errorRenderedByComponent = true
input = (
<Input
id={id}
type={param.type === 'number' ? 'number' : 'text'}
value={value}
onChange={handleChange}
placeholder={
param.type === 'multi_text'
? 'Comma-separated values'
: (param.placeholder ?? undefined)
}
disabled={disabled}
error={error}
/>
)
} else if (param.type === 'password') {
errorRenderedByComponent = true
input = (
<div className="relative">
<Input
id={id}
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleChange}
placeholder={param.placeholder ?? undefined}
disabled={disabled}
error={error}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)
} else if (param.type === 'textarea') {
errorRenderedByComponent = true
input = (
<Textarea
id={id}
value={value}
onChange={handleChange}
placeholder={param.placeholder ?? undefined}
disabled={disabled}
rows={4}
error={error}
/>
)
} else if (param.type === 'select') {
input = (
<select
id={id}
value={value}
onChange={handleChange}
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"
>
<option value="">Select</option>
{(param.options ?? []).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
} else if (param.type === 'boolean') {
input = (
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={value === 'true'}
onChange={handleCheckbox}
disabled={disabled}
className="rounded border-border disabled:cursor-not-allowed disabled:opacity-50"
/>
<label htmlFor={id} className="text-sm text-foreground">
{param.label}
</label>
</div>
)
} else {
// Fallback for unknown types
errorRenderedByComponent = true
input = (
<Input
id={id}
value={value}
onChange={handleChange}
disabled={disabled}
error={error}
/>
)
}
// Boolean renders its own label inline; all others show the label above
const showTopLabel = param.type !== 'boolean'
return (
<div className="flex flex-col gap-1">
{showTopLabel && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{param.label}
{param.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
)}
{input}
{param.help_text && (
<p className="text-xs text-muted-foreground mt-1">{param.help_text}</p>
)}
{!errorRenderedByComponent && error && (
<p className="mt-1.5 text-xs text-red-400">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Terminal } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { ScriptParameterField } from './ScriptParameterField'
import type { ScriptParametersSchema, ScriptParameter } from '@/types'
interface Props {
canGenerate: boolean
}
export function ScriptParameterForm({ canGenerate }: Props) {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const paramValues = useScriptGeneratorStore(s => s.paramValues)
const formErrors = useScriptGeneratorStore(s => s.formErrors)
if (!selectedTemplate) return null
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = (schema?.parameters ?? []).slice().sort((a, b) => a.order - b.order)
// Group parameters: null-group first, then named groups in order of first appearance
const ungrouped = parameters.filter(p => p.group === null)
const groupOrder: string[] = []
const grouped: Record<string, ScriptParameter[]> = {}
for (const p of parameters) {
if (p.group !== null) {
if (!grouped[p.group]) {
grouped[p.group] = []
groupOrder.push(p.group)
}
grouped[p.group].push(p)
}
}
const renderParam = (param: ScriptParameter) => (
<ScriptParameterField
key={param.key}
param={param}
value={paramValues[param.key] ?? ''}
error={formErrors[param.key] || undefined}
disabled={!canGenerate}
/>
)
if (parameters.length === 0) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-white/[0.02] px-3 py-3">
<Terminal size={14} className="text-muted-foreground shrink-0" />
<p className="text-xs text-muted-foreground">
This template has no parameters click <span className="text-foreground font-medium">Generate</span> to produce the script.
</p>
</div>
)
}
return (
<div className="flex flex-col gap-4">
{ungrouped.map(renderParam)}
{groupOrder.map(group => (
<div key={group}>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
{group}
</p>
<div className="flex flex-col gap-4">
{grouped[group].map(renderParam)}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { PowerShellHighlighter } from './PowerShellHighlighter'
import type { ScriptParametersSchema } from '@/types'
export function ScriptPreview() {
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const paramValues = useScriptGeneratorStore(s => s.paramValues)
const generatedScript = useScriptGeneratorStore(s => s.generatedScript)
const [copied, setCopied] = useState(false)
if (!selectedTemplate) return null
// Compute the displayed script
let displayScript: string
if (generatedScript !== null) {
displayScript = generatedScript
} else {
// Draft mode: client-side {{key}} substitution
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
displayScript = selectedTemplate.script_body
for (const param of parameters) {
const placeholder = `{{${param.key}}}`
const replacement = param.sensitive
? '****'
: (paramValues[param.key] ?? '')
displayScript = displayScript.replaceAll(placeholder, replacement || placeholder)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(displayScript)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silently fail — no error displayed
}
}
return (
<div className="relative">
<button
onClick={handleCopy}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
<PowerShellHighlighter script={displayScript} />
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { FileCode, Search } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { TemplateCard } from './TemplateCard'
interface Props {
inputValue: string
onClearSearch: () => void
onConfigure: (id: string) => void
}
function TemplateSkeleton() {
return (
<div className="px-4 py-3 rounded-xl border border-border animate-pulse">
<div className="flex justify-between mb-2">
<div className="h-4 w-2/3 bg-white/10 rounded" />
<div className="h-4 w-14 bg-white/10 rounded" />
</div>
<div className="h-3 w-full bg-white/5 rounded mb-1" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
)
}
export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: Props) {
const templates = useScriptGeneratorStore(s => s.templates)
const isLoadingTemplates = useScriptGeneratorStore(s => s.isLoadingTemplates)
if (isLoadingTemplates) {
return (
<div className="flex flex-col gap-2 p-2">
<TemplateSkeleton />
<TemplateSkeleton />
<TemplateSkeleton />
</div>
)
}
if (templates.length === 0) {
if (inputValue !== '') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<Search size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates match your search</p>
<button
type="button"
onClick={onClearSearch}
className="text-xs text-primary hover:underline"
>
Clear search
</button>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
<FileCode size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No templates found</p>
</div>
)
}
return (
<div className="flex flex-col gap-2 p-2">
{templates.map(template => (
<TemplateCard key={template.id} template={template} onConfigure={onConfigure} />
))}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { ShieldAlert } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10',
intermediate: 'text-amber-400 bg-amber-400/10',
advanced: 'text-rose-500 bg-rose-500/10',
}
interface Props {
template: ScriptTemplateListItem
onConfigure: (id: string) => void
}
export function TemplateCard({ template, onConfigure }: Props) {
return (
<div
className={cn(
'w-full px-4 py-3 rounded-xl border transition-all',
'border-border bg-transparent'
)}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm font-medium text-foreground line-clamp-1">
{template.name}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && (
<span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" />
</span>
)}
<span className={cn('font-label text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
{template.complexity}
</span>
</div>
</div>
{template.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{template.description}
</p>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-[0.625rem] text-muted-foreground font-label">
<span>{template.usage_count}× used</span>
{template.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{template.tags.slice(0, 3).map(tag => (
<span key={tag} className="bg-white/5 border border-border rounded px-1.5 py-0.5">
{tag}
</span>
))}
{template.tags.length > 3 && (
<span className="text-muted-foreground">+{template.tags.length - 3}</span>
)}
</div>
)}
</div>
<button
type="button"
onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
>
Configure
</button>
</div>
</div>
)
}

View File

@@ -71,5 +71,16 @@ export function usePermissions() {
canManageCategories: hasMinimumRole(user, 'owner'),
canManageGlobalCategories: effectiveRole === 'super_admin',
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner',
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'),
}
}

View File

@@ -0,0 +1,300 @@
import type { ScriptParameter, ParameterCandidate } from '@/types'
/**
* PowerShell variable names to skip — these are PS internals, not user inputs.
*/
const SKIP_VARIABLES = new Set([
'$ErrorActionPreference',
'$WarningPreference',
'$VerbosePreference',
'$DebugPreference',
'$InformationPreference',
'$ConfirmPreference',
'$ProgressPreference',
'$PSDefaultParameterValues',
'$PSModuleAutoLoadingPreference',
'$OFS',
'$FormatEnumerationLimit',
'$MaximumHistoryCount',
'$_',
'$PSItem',
'$args',
'$input',
'$this',
'$null',
'$true',
'$false',
])
/**
* Sensitive variable name patterns — if the variable name contains any of these,
* suggest password type and mark sensitive.
*/
const SENSITIVE_PATTERNS = /password|secret|key|credential|token|apikey|api_key/i
/**
* Convert a PowerShell variable name to a snake_case key.
* "$OUPath" → "ou_path", "$ServerName" → "server_name"
*/
function toSnakeCase(varName: string): string {
const name = varName.replace(/^\$/, '')
return name
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
}
/**
* Convert a snake_case key to a human-readable label.
* "ou_path" → "OU Path", "server_name" → "Server Name"
*/
function toLabel(key: string): string {
return key
.split('_')
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/**
* Infer the ScriptParameter type from a PowerShell type annotation and/or value.
*/
function inferType(
typeAnnotation: string | null,
value: string | null,
varName: string
): { type: ScriptParameter['type']; sensitive: boolean; reason: string } {
if (typeAnnotation) {
const t = typeAnnotation.toLowerCase()
if (t === 'switch') {
return { type: 'boolean', sensitive: false, reason: 'Detected [switch] type declaration' }
}
if (t === 'securestring') {
return { type: 'password', sensitive: true, reason: 'Detected [SecureString] type — marked as sensitive' }
}
if (t === 'int' || t === 'int32' || t === 'int64' || t === 'double' || t === 'float' || t === 'decimal') {
return { type: 'number', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
if (t === 'bool' || t === 'boolean') {
return { type: 'boolean', sensitive: false, reason: `Detected [${typeAnnotation}] type declaration` }
}
}
if (SENSITIVE_PATTERNS.test(varName)) {
return { type: 'password', sensitive: true, reason: 'Variable name suggests sensitive data — marked as sensitive' }
}
if (value !== null) {
const trimmed = value.trim()
if (trimmed === '$true' || trimmed === '$false') {
return { type: 'boolean', sensitive: false, reason: 'Detected boolean value ($true/$false)' }
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return { type: 'number', sensitive: false, reason: 'Detected numeric value' }
}
}
const reason = typeAnnotation
? `Detected [${typeAnnotation}] type declaration`
: 'Defaulting to text (no type annotation detected)'
return { type: 'text', sensitive: false, reason }
}
/**
* Parse the default value into the correct JS type.
*/
function parseDefault(value: string | null, type: ScriptParameter['type']): string | boolean | number | null {
if (value === null) return null
const trimmed = value.trim()
if (type === 'boolean') {
if (trimmed === '$true') return true
if (trimmed === '$false') return false
return null
}
if (type === 'number') {
const n = Number(trimmed)
return isNaN(n) ? null : n
}
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1)
}
return trimmed
}
/**
* Find the script-level param() block (not inside any function).
*/
function findScriptLevelParamBlock(script: string): { start: number; end: number } | null {
const lines = script.split('\n')
let functionBraceDepth = 0
let paramStart = -1
let parenDepth = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
// Track function blocks with brace depth to handle nested braces
if (/^function\s+/i.test(trimmed)) {
// Count braces on this line and subsequent lines
for (const ch of lines[i]) {
if (ch === '{') functionBraceDepth++
if (ch === '}') functionBraceDepth--
}
continue
}
// Track braces while inside a function
if (functionBraceDepth > 0) {
for (const ch of lines[i]) {
if (ch === '{') functionBraceDepth++
if (ch === '}') functionBraceDepth--
}
continue
}
if (functionBraceDepth === 0 && /^param\s*\(/i.test(trimmed) && paramStart === -1) {
paramStart = i
let foundOpen = false
for (let j = i; j < lines.length; j++) {
for (const ch of lines[j]) {
if (ch === '(') { parenDepth++; foundOpen = true }
if (ch === ')') parenDepth--
if (foundOpen && parenDepth === 0) {
return { start: paramStart, end: j }
}
}
}
}
}
return null
}
/**
* Extract parameter candidates from a script-level param() block.
*/
function extractParamBlockCandidates(
script: string,
block: { start: number; end: number }
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
// Scan each line in the param block for $VarName patterns.
// Lines with [Parameter(...)], [ValidateSet(...)], etc. don't contain $VarName
// so they are naturally skipped. The type annotation [string], [int], etc.
// appears on the same line as $VarName.
const varLineRegex = /(?:\[(\w+)\])?\s*\$(\w+)(?:\s*=\s*(.+?))?(?:\s*,?\s*$)/
for (let i = block.start; i <= block.end; i++) {
const trimmed = lines[i].trim()
// Skip attribute lines like [Parameter(...)], [ValidateSet(...)], etc.
if (/^\[(?:Parameter|ValidateSet|ValidateRange|ValidatePattern|ValidateScript|ValidateLength|ValidateCount|Alias|AllowNull|AllowEmptyString|AllowEmptyCollection)\s*\(/i.test(trimmed)) {
continue
}
const match = trimmed.match(varLineRegex)
if (!match) continue
const typeAnnotation = match[1] || null
const varName = match[2]
const rawDefault = match[3]?.trim() ?? null
// Skip if type looks like an attribute we didn't catch above
if (typeAnnotation && /^Parameter$/i.test(typeAnnotation)) continue
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(typeAnnotation, rawDefault, varName)
const defaultValue = parseDefault(rawDefault, type)
candidates.push({
variableName: `$${varName}`,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'param_block',
lineNumber: i + 1,
matchedLine: trimmed,
inferenceReason: reason,
})
}
return candidates
}
/**
* Extract parameter candidates from variable assignments ($Var = 'value').
*/
function extractAssignmentCandidates(
script: string,
existingVarNames: Set<string>
): ParameterCandidate[] {
const lines = script.split('\n')
const candidates: ParameterCandidate[] = []
const seenVars = new Set<string>()
const assignRegex = /^\s*(\$\w+)\s*=\s*(.+)$/
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(assignRegex)
if (!match) continue
const fullVar = match[1]
const rawValue = match[2].trim()
if (SKIP_VARIABLES.has(fullVar)) continue
if (existingVarNames.has(fullVar)) continue
if (seenVars.has(fullVar)) continue
if (!/^['"].*['"]$/.test(rawValue) &&
!/^-?\d+(\.\d+)?$/.test(rawValue) &&
!/^\$(true|false)$/i.test(rawValue)) {
continue
}
if (/\{\{.*?\}\}/.test(rawValue)) continue
seenVars.add(fullVar)
const varName = fullVar.replace(/^\$/, '')
const key = toSnakeCase(varName)
const { type, sensitive, reason } = inferType(null, rawValue, varName)
const defaultValue = parseDefault(rawValue, type)
candidates.push({
variableName: fullVar,
suggestedKey: key,
suggestedLabel: toLabel(key),
suggestedType: type,
sensitive,
defaultValue,
source: 'assignment',
lineNumber: i + 1,
matchedLine: lines[i].trim(),
inferenceReason: reason,
})
}
return candidates
}
/**
* Detect parameter candidates in a PowerShell script body.
*/
export function detectParameterCandidates(script: string): ParameterCandidate[] {
if (!script.trim()) return []
const paramBlock = findScriptLevelParamBlock(script)
const paramCandidates = paramBlock
? extractParamBlockCandidates(script, paramBlock)
: []
const paramVarNames = new Set(paramCandidates.map(c => c.variableName))
const assignmentCandidates = extractAssignmentCandidates(script, paramVarNames)
return [...paramCandidates, ...assignmentCandidates]
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Terminal, Settings } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
export default function ScriptLibraryPage() {
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
// inputValue owned here so it survives Configure ↔ Browse transitions
const [inputValue, setInputValue] = useState('')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
const setSearch = useScriptGeneratorStore(s => s.setSearch)
const selectTemplate = useScriptGeneratorStore(s => s.selectTemplate)
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
const { isEngineer } = usePermissions()
const canGenerate = isEngineer
useEffect(() => {
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
const onClearSearch = () => {
setInputValue('')
setSearch('')
}
const onConfigure = (id: string) => {
selectTemplate(id)
setPaneMode('configure')
}
const onBack = () => {
clearOutput()
setPaneMode('browse')
}
return (
<div className="flex flex-col gap-4 p-6 h-full">
{/* Page header */}
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
{isEngineer && (
<Link
to="/scripts/manage"
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
>
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
Manage Templates
</Link>
)}
</div>
{/* Two-column layout */}
<div className="grid grid-cols-[320px_1fr] gap-4 flex-1 min-h-0">
{/* Left pane — Browse or Configure mode */}
{paneMode === 'browse' ? (
<div className="glass-card-static flex flex-col overflow-hidden">
<div className="p-2 pb-0">
<ScriptFilterBar inputValue={inputValue} setInputValue={setInputValue} />
</div>
<div className="flex-1 overflow-y-auto">
<ScriptTemplateList
inputValue={inputValue}
onClearSearch={onClearSearch}
onConfigure={onConfigure}
/>
</div>
</div>
) : (
<ScriptConfigurePane canGenerate={canGenerate} onBack={onBack} />
)}
{/* Right pane — read-only ScriptPreview */}
{selectedTemplate === null ? (
<div className="glass-card-static h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<Terminal size={40} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">Select a template to get started</p>
</div>
) : (
<div className="glass-card-static h-full overflow-y-auto p-4">
<ScriptPreview />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,44 @@
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="h-full overflow-y-auto">
<div className="p-6 max-w-5xl mx-auto">
{mode === 'list' ? (
<ScriptTemplateListView onEdit={handleEdit} onCreate={handleCreate} />
) : (
<ScriptTemplateEditor
templateId={editingId}
onBack={handleBack}
onSaved={handleSaved}
/>
)}
</div>
</div>
)
}

View File

@@ -41,6 +41,8 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
@@ -160,6 +162,8 @@ export const router = sentryCreateBrowserRouter([
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
{ path: 'feedback', element: page(FeedbackPage) },
{ path: 'step-library', element: page(StepLibraryPage) },
{ path: 'scripts', element: page(ScriptLibraryPage) },
{ path: 'scripts/manage', element: page(ScriptManagePage) },
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'guides', element: page(GuidesHubPage) },

View File

@@ -0,0 +1,192 @@
import { create } from 'zustand'
import { scriptsApi } from '@/api'
import type {
ScriptCategoryResponse,
ScriptTemplateListItem,
ScriptTemplateDetail,
ScriptParametersSchema,
} from '@/types'
interface ScriptGeneratorState {
// Template browsing
categories: ScriptCategoryResponse[]
templates: ScriptTemplateListItem[]
selectedTemplate: ScriptTemplateDetail | null
searchQuery: string
activeCategoryId: string | null // null = "All"
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
isLoadingDetail: boolean // drives spinner in ScriptConfigurePane
// Form
paramValues: Record<string, string> // keyed by ScriptParameter.key; booleans as 'true'/'false'
formErrors: Record<string, string> // keyed by ScriptParameter.key
// Output
generatedScript: string | null
generationId: string | null
generationWarnings: string[]
isGenerating: boolean
generateError: string | null
// Actions
loadCategories: () => Promise<void>
loadTemplates: () => Promise<void>
selectTemplate: (id: string) => Promise<void>
setCategory: (id: string | null) => void
setSearch: (query: string) => void
setParamValue: (key: string, value: string) => void
validate: () => boolean
generate: (sessionId?: string) => Promise<void>
clearOutput: () => void
reset: () => void
}
export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get) => ({
// Initial state
categories: [],
templates: [],
selectedTemplate: null,
searchQuery: '',
activeCategoryId: null,
isLoadingTemplates: false,
isLoadingDetail: false,
paramValues: {},
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
isGenerating: false,
generateError: null,
loadCategories: async () => {
try {
const categories = await scriptsApi.getCategories()
set({ categories })
} catch {
// silently ignore — categories remain empty, UI degrades gracefully
}
},
loadTemplates: async () => {
set({ isLoadingTemplates: true })
try {
const { activeCategoryId, categories, searchQuery } = get()
const category = categories.find(c => c.id === activeCategoryId)
const params: { category_slug?: string; search?: string } = {}
if (category) params.category_slug = category.slug
if (searchQuery) params.search = searchQuery
const templates = await scriptsApi.getTemplates(params)
set({ templates, isLoadingTemplates: false })
} catch {
set({ isLoadingTemplates: false })
}
},
selectTemplate: async (id: string) => {
set({ isLoadingDetail: true })
try {
const detail = await scriptsApi.getTemplateDetail(id)
// Populate paramValues from parameter defaults
const schema = detail.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
const paramValues: Record<string, string> = {}
for (const param of parameters) {
const d = param.default
if (d === null || d === undefined) paramValues[param.key] = ''
else if (typeof d === 'boolean') paramValues[param.key] = d ? 'true' : 'false'
else paramValues[param.key] = String(d)
}
set({
selectedTemplate: detail,
paramValues,
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
isLoadingDetail: false,
})
} catch {
set({ isLoadingDetail: false })
}
},
setCategory: (id: string | null) => {
set({ activeCategoryId: id })
get().loadTemplates()
},
setSearch: (query: string) => {
set({ searchQuery: query })
get().loadTemplates()
},
setParamValue: (key: string, value: string) => {
set(state => ({
paramValues: { ...state.paramValues, [key]: value },
formErrors: { ...state.formErrors, [key]: '' }, // clear error on change
}))
},
validate: () => {
const { selectedTemplate, paramValues } = get()
if (!selectedTemplate) return true
const schema = selectedTemplate.parameters_schema as ScriptParametersSchema
const parameters = schema?.parameters ?? []
const errors: Record<string, string> = {}
for (const param of parameters) {
if (param.required && !paramValues[param.key]) {
errors[param.key] = `${param.label} is required`
}
}
set({ formErrors: errors })
return Object.keys(errors).length === 0
},
generate: async (sessionId?: string) => {
const { selectedTemplate, paramValues } = get()
if (!selectedTemplate) return
if (!get().validate()) return
set({ isGenerating: true, generateError: null })
try {
const response = await scriptsApi.generate({
template_id: selectedTemplate.id,
parameters: paramValues,
...(sessionId ? { session_id: sessionId } : {}),
})
set({
generatedScript: response.script,
generationId: response.id,
generationWarnings: response.warnings,
isGenerating: false,
})
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } }
const message = axiosErr.response?.data?.detail ?? 'Failed to generate script'
set({ generateError: message, isGenerating: false })
}
},
clearOutput: () => {
set({
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
})
},
// Exposed for Phase 3 callers (session execution context).
// Does NOT clear selectedTemplate, categories, templates, or browsing state.
reset: () => {
set({
paramValues: {},
formErrors: {},
generatedScript: null,
generationId: null,
generationWarnings: [],
generateError: null,
})
},
}))

View File

@@ -87,3 +87,5 @@ export type {
KBCommitResponse,
KBQuotaResponse,
} from './kbAccelerator'
export * from './scripts'

View File

@@ -0,0 +1,137 @@
export interface ScriptCategoryResponse {
id: string
name: string
slug: string
description: string | null
icon: string | null
sort_order: number
template_count: number
}
export interface ScriptTemplateListItem {
id: string
category_id: string
team_id: string | null
created_by: string | null
name: string
slug: string
description: string | null
tags: string[]
complexity: 'beginner' | 'intermediate' | 'advanced' // must match backend ScriptComplexity enum exactly
estimated_runtime: string | null
requires_elevation: boolean
requires_modules: string[]
is_verified: boolean
usage_count: number
}
export interface ScriptParameterOption {
value: string
label: string
}
export interface ScriptParameterValidation {
min_value?: number // matches backend field name (not 'min')
max_value?: number // matches backend field name (not 'max')
pattern?: string
min_length?: number
max_length?: number
}
export interface ScriptParameter {
key: string
label: string
type: 'text' | 'password' | 'select' | 'boolean' | 'multi_text' | 'number' | 'textarea'
required: boolean
placeholder: string | null
group: string | null
order: number
help_text: string | null
options: ScriptParameterOption[] | null // for select type
default: string | boolean | number | null
validation: ScriptParameterValidation | null
sensitive: boolean
}
export interface ScriptParametersSchema {
parameters: ScriptParameter[]
}
export interface ScriptTemplateDetail extends ScriptTemplateListItem {
use_case: string | null
script_body: string
// NOTE: backend types this as `dict` — arrives as unknown at runtime.
// Always access via cast: (detail.parameters_schema as ScriptParametersSchema).parameters ?? []
parameters_schema: ScriptParametersSchema
default_values: Record<string, unknown> // template-level metadata; not used in Phase 2
validation_rules: Record<string, unknown> // template-level metadata; not used in Phase 2
version: number
created_at: string
updated_at: string
}
export interface ScriptGenerateRequest {
template_id: string
parameters: Record<string, unknown>
session_id?: string // Phase 3: passed when generating inside a session
}
export interface ScriptGenerateResponse {
id: string // generation UUID
script: string // rendered PowerShell
warnings: string[]
metadata: {
template_name: string
template_version: number
requires_elevation: boolean
[key: string]: unknown
}
}
export interface ScriptGenerationRecord {
id: string
template_id: string
template_name: string
parameters_used: Record<string, unknown> // sensitive values already redacted by backend
created_at: string
}
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[]
}
export interface ParameterCandidate {
variableName: string
suggestedKey: string
suggestedLabel: string
suggestedType: ScriptParameter['type']
sensitive: boolean
defaultValue: string | boolean | number | null
source: 'param_block' | 'assignment'
lineNumber: number
matchedLine: string
inferenceReason: string
}