feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector
Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #105.
This commit is contained in:
71
CLAUDE.md
71
CLAUDE.md
@@ -30,6 +30,7 @@
|
|||||||
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
|
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
|
||||||
|
|
||||||
**Component styling rules:**
|
**Component styling rules:**
|
||||||
|
|
||||||
- Primary buttons: `bg-gradient-brand` (cyan `135deg`) with `shadow-lg shadow-primary/20`, hover `opacity-0.9`, active `scale(0.97)`
|
- 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
|
- 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
|
- 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
|
- Prefer correct architecture over minimal diff
|
||||||
- If two approaches exist, implement the one that scales, not the one that's faster to write
|
- 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
|
- Flag any "simpler approach" tradeoffs for product owner review before proceeding
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
@@ -77,6 +79,7 @@ When adding new pages/components: use "ResolutionFlow" branding, ice-cyan gradie
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- **Framework:** Python FastAPI
|
- **Framework:** Python FastAPI
|
||||||
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
- **Database:** PostgreSQL 16 (async via SQLAlchemy 2.0 + asyncpg)
|
||||||
- **Migrations:** Alembic
|
- **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
|
- **Scheduling:** APScheduler 3.x (async, in-process with FastAPI lifespan) + croniter + pytz
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- **Framework:** React 19 + Vite + TypeScript
|
- **Framework:** React 19 + Vite + TypeScript
|
||||||
- **Styling:** Tailwind CSS v3 — dark-first with purple gradient accents (see Branding section)
|
- **Styling:** Tailwind CSS v3 — dark-first with purple gradient accents (see Branding section)
|
||||||
- **State:** Zustand (with immer + zundo for undo/redo)
|
- **State:** Zustand (with immer + zundo for undo/redo)
|
||||||
@@ -131,6 +135,7 @@ patherly/
|
|||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
### Backend (`backend/.env`)
|
### Backend (`backend/.env`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_NAME=ResolutionFlow
|
APP_NAME=ResolutionFlow
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
@@ -143,12 +148,54 @@ REQUIRE_INVITE_CODE=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Frontend (`frontend/.env.local` - optional)
|
### Frontend (`frontend/.env.local` - optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_URL=http://localhost:8000
|
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
|
## Development Commands
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -190,11 +237,13 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
|
|||||||
```
|
```
|
||||||
|
|
||||||
### URLs
|
### URLs
|
||||||
- Frontend: http://localhost:5173
|
|
||||||
- Backend API: http://localhost:8000
|
- Frontend: <http://localhost:5173>
|
||||||
- API Docs: http://localhost:8000/api/docs
|
- Backend API: <http://localhost:8000>
|
||||||
|
- API Docs: <http://localhost:8000/api/docs>
|
||||||
|
|
||||||
### Test Users (seeded via `scripts/seed_test_users.py`)
|
### Test Users (seeded via `scripts/seed_test_users.py`)
|
||||||
|
|
||||||
- All share password: `TestPass123!`
|
- 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)
|
- `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)
|
### Top Gotchas (most commonly hit)
|
||||||
|
|
||||||
**1. DateTime Handling — Always timezone-aware:**
|
**1. DateTime Handling — Always timezone-aware:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# CORRECT
|
# CORRECT
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
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:**
|
**2. SQLAlchemy Async — No lazy loading on new objects:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# WRONG — MissingGreenlet error
|
# WRONG — MissingGreenlet error
|
||||||
new_tree = Tree(...); db.add(new_tree); await db.flush()
|
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:**
|
**3. React State — Don't store object snapshots:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// WRONG — snapshot won't update
|
// WRONG — snapshot won't update
|
||||||
const [editingNode, setEditingNode] = useState(node)
|
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:**
|
**4. Modal Draft State — Exclude store-managed fields:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { children, ...draftWithoutChildren } = draft
|
const { children, ...draftWithoutChildren } = draft
|
||||||
updateNode(node.id, draftWithoutChildren) // Don't overwrite children
|
updateNode(node.id, draftWithoutChildren) // Don't overwrite children
|
||||||
```
|
```
|
||||||
|
|
||||||
**5. Multiple FKs to same table — Specify `foreign_keys` on BOTH sides:**
|
**5. Multiple FKs to same table — Specify `foreign_keys` on BOTH sides:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
author = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
author = relationship("User", foreign_keys=[author_id], back_populates="trees")
|
||||||
```
|
```
|
||||||
|
|
||||||
**6. PostgreSQL NULL in UUID columns:**
|
**6. PostgreSQL NULL in UUID columns:**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT 'tag', 'slug', NULL::uuid as team_id -- Must cast NULL to uuid
|
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).
|
**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:**
|
**10. React Router — Clear dirty state before navigation:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
markSaved() // Clear isDirty BEFORE navigate()
|
markSaved() // Clear isDirty BEFORE navigate()
|
||||||
navigate(`/trees/${newTree.id}/edit`)
|
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()`.
|
**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
|
## RBAC & Permissions
|
||||||
@@ -408,19 +466,24 @@ navigate(`/trees/${newTree.id}/edit`)
|
|||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
|
|
||||||
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
- Type hints everywhere, async/await for DB, Pydantic for validation, `DateTime(timezone=True)` always
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
- Interfaces for all data, `const` over `let`, functional components + hooks, reusable logic in custom hooks
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
|
|
||||||
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
- Format: `type: description` (feat, fix, refactor, docs, test, chore)
|
||||||
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
- Always include `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`
|
||||||
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
- Always create feature branch BEFORE committing: `git checkout -b feat/feature-name`
|
||||||
- Large features: commit per phase with `npm run build` validation
|
- Large features: commit per phase with `npm run build` validation
|
||||||
|
|
||||||
### After Completing Work
|
### After Completing Work
|
||||||
|
|
||||||
When a feature, fix, or significant piece of work is finished and merged/committed:
|
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
|
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
|
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
|
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 |
|
| What | Where |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| API Docs | http://localhost:8000/api/docs |
|
| API Docs | <http://localhost:8000/api/docs> |
|
||||||
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
| Detailed Status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||||
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
| Development Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||||
| GitHub Issues | `gh issue list --state open` |
|
| GitHub Issues | `gh issue list --state open` |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.models.survey_response import SurveyResponse
|
|||||||
from app.models.survey_invite import SurveyInvite
|
from app.models.survey_invite import SurveyInvite
|
||||||
from app.models.ai_suggestion import AISuggestion # noqa: F401
|
from app.models.ai_suggestion import AISuggestion # noqa: F401
|
||||||
from app.models.kb_import import KBImport, KBImportNode # 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
|
from app.core.config import settings
|
||||||
|
|
||||||
# this is the Alembic Config object
|
# this is the Alembic Config object
|
||||||
|
|||||||
690
backend/alembic/versions/057_add_script_templates.py
Normal file
690
backend/alembic/versions/057_add_script_templates.py
Normal 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"
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
|
||||||
|
]
|
||||||
413
backend/app/api/endpoints/scripts.py
Normal file
413
backend/app/api/endpoints/scripts.py
Normal 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
|
||||||
|
]
|
||||||
@@ -16,6 +16,7 @@ from app.api.endpoints import tree_transfer
|
|||||||
from app.api.endpoints import ai_suggestions
|
from app.api.endpoints import ai_suggestions
|
||||||
from app.api.endpoints import kb_accelerator
|
from app.api.endpoints import kb_accelerator
|
||||||
from app.api.endpoints import beta_signup
|
from app.api.endpoints import beta_signup
|
||||||
|
from app.api.endpoints import scripts
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(ai_suggestions.router)
|
||||||
api_router.include_router(kb_accelerator.router)
|
api_router.include_router(kb_accelerator.router)
|
||||||
api_router.include_router(beta_signup.router)
|
api_router.include_router(beta_signup.router)
|
||||||
|
api_router.include_router(scripts.router)
|
||||||
|
|||||||
@@ -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:
|
if user.account_role == "owner" and account_id == user.account_id and user.account_id is not None:
|
||||||
return True
|
return True
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .assistant_chat import AssistantChat
|
|||||||
from .survey_response import SurveyResponse
|
from .survey_response import SurveyResponse
|
||||||
from .survey_invite import SurveyInvite
|
from .survey_invite import SurveyInvite
|
||||||
from .kb_import import KBImport, KBImportNode
|
from .kb_import import KBImport, KBImportNode
|
||||||
|
from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -82,4 +83,7 @@ __all__ = [
|
|||||||
"SurveyInvite",
|
"SurveyInvite",
|
||||||
"KBImport",
|
"KBImport",
|
||||||
"KBImportNode",
|
"KBImportNode",
|
||||||
|
"ScriptCategory",
|
||||||
|
"ScriptTemplate",
|
||||||
|
"ScriptGeneration",
|
||||||
]
|
]
|
||||||
|
|||||||
107
backend/app/models/script_template.py
Normal file
107
backend/app/models/script_template.py
Normal 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")
|
||||||
@@ -10,6 +10,11 @@ from .ai_builder import (
|
|||||||
AIStartResponse, AIScaffoldResponse, AIBranchDetailResponse, AIAssembleResponse,
|
AIStartResponse, AIScaffoldResponse, AIBranchDetailResponse, AIAssembleResponse,
|
||||||
AIQuotaStatusResponse,
|
AIQuotaStatusResponse,
|
||||||
)
|
)
|
||||||
|
from .script_template import (
|
||||||
|
ScriptCategoryResponse,
|
||||||
|
ScriptTemplateCreate, ScriptTemplateUpdate, ScriptTemplateListItem, ScriptTemplateDetail,
|
||||||
|
ScriptGenerateRequest, ScriptGenerateResponse, ScriptGenerationRecord,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -30,4 +35,8 @@ __all__ = [
|
|||||||
"AIStartRequest", "AIScaffoldRequest", "AIBranchDetailRequest", "AIAssembleRequest",
|
"AIStartRequest", "AIScaffoldRequest", "AIBranchDetailRequest", "AIAssembleRequest",
|
||||||
"AIStartResponse", "AIScaffoldResponse", "AIBranchDetailResponse", "AIAssembleResponse",
|
"AIStartResponse", "AIScaffoldResponse", "AIBranchDetailResponse", "AIAssembleResponse",
|
||||||
"AIQuotaStatusResponse",
|
"AIQuotaStatusResponse",
|
||||||
|
# Script Generator
|
||||||
|
"ScriptCategoryResponse",
|
||||||
|
"ScriptTemplateCreate", "ScriptTemplateUpdate", "ScriptTemplateListItem", "ScriptTemplateDetail",
|
||||||
|
"ScriptGenerateRequest", "ScriptGenerateResponse", "ScriptGenerationRecord",
|
||||||
]
|
]
|
||||||
|
|||||||
138
backend/app/schemas/script_template.py
Normal file
138
backend/app/schemas/script_template.py
Normal 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
|
||||||
139
backend/app/services/script_template_engine.py
Normal file
139
backend/app/services/script_template_engine.py
Normal 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("'", "''")
|
||||||
@@ -20,7 +20,13 @@ from app.core.config import settings
|
|||||||
settings.REQUIRE_INVITE_CODE = False
|
settings.REQUIRE_INVITE_CODE = False
|
||||||
|
|
||||||
# Test database URL (separate from production)
|
# 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")
|
@pytest.fixture(scope="session")
|
||||||
|
|||||||
114
backend/tests/test_script_template_engine.py
Normal file
114
backend/tests/test_script_template_engine.py
Normal 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]"
|
||||||
238
backend/tests/test_script_templates.py
Normal file
238
backend/tests/test_script_templates.py
Normal 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
|
||||||
336
backend/tests/test_scripts.py
Normal file
336
backend/tests/test_scripts.py
Normal 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
|
||||||
157
docs/plans/2026-03-13-script-template-editor-design.md
Normal file
157
docs/plans/2026-03-13-script-template-editor-design.md
Normal 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)
|
||||||
2253
docs/plans/2026-03-13-script-template-editor-impl.md
Normal file
2253
docs/plans/2026-03-13-script-template-editor-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
116
docs/plans/2026-03-14-parameter-detector-design.md
Normal file
116
docs/plans/2026-03-14-parameter-detector-design.md
Normal 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
|
||||||
920
docs/plans/2026-03-14-parameter-detector-plan.md
Normal file
920
docs/plans/2026-03-14-parameter-detector-plan.md
Normal 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 & Finish</>
|
||||||
|
) : (
|
||||||
|
<><ChevronRight size={13} /> Accept & 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
|
||||||
|
```
|
||||||
1602
docs/superpowers/plans/2026-03-13-script-generator-phase2.md
Normal file
1602
docs/superpowers/plans/2026-03-13-script-generator-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -21,3 +21,4 @@ export { copilotApi } from './copilot'
|
|||||||
export { assistantChatApi } from './assistantChat'
|
export { assistantChatApi } from './assistantChat'
|
||||||
export { flowTransferApi } from './flowTransfer'
|
export { flowTransferApi } from './flowTransfer'
|
||||||
export { kbAcceleratorApi } from './kbAccelerator'
|
export { kbAcceleratorApi } from './kbAccelerator'
|
||||||
|
export { scriptsApi } from './scripts'
|
||||||
|
|||||||
78
frontend/src/api/scripts.ts
Normal file
78
frontend/src/api/scripts.ts
Normal 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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Sentry from '@sentry/react'
|
import * as Sentry from '@sentry/react'
|
||||||
import { type ReactNode } from 'react'
|
import { type ReactNode, useEffect, useRef } from 'react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
interface FallbackProps {
|
interface FallbackProps {
|
||||||
@@ -18,17 +18,20 @@ function isChunkLoadError(error: Error): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DefaultFallback({ error, resetError }: FallbackProps) {
|
function DefaultFallback({ error, resetError }: FallbackProps) {
|
||||||
|
const reloadingRef = useRef(false)
|
||||||
|
|
||||||
// Auto-reload on stale chunk errors (happens after deployments)
|
// Auto-reload on stale chunk errors (happens after deployments)
|
||||||
if (isChunkLoadError(error)) {
|
useEffect(() => {
|
||||||
|
if (!isChunkLoadError(error)) return
|
||||||
const key = 'rf_boundary_chunk_reload'
|
const key = 'rf_boundary_chunk_reload'
|
||||||
const lastReload = sessionStorage.getItem(key)
|
const lastReload = sessionStorage.getItem(key)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (!lastReload || now - Number(lastReload) > 10_000) {
|
if (!lastReload || now - Number(lastReload) > 10_000) {
|
||||||
sessionStorage.setItem(key, String(now))
|
sessionStorage.setItem(key, String(now))
|
||||||
|
reloadingRef.current = true
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}, [error])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-8">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useLocation, useNavigate, Link } from 'react-router-dom'
|
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 { useAuthStore } from '@/store/authStore'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
@@ -57,6 +57,7 @@ export function AppLayout() {
|
|||||||
{ path: '/sessions', label: 'Sessions', icon: Clock },
|
{ path: '/sessions', label: 'Sessions', icon: Clock },
|
||||||
{ path: '/shares', label: 'Exports', icon: FileText },
|
{ path: '/shares', label: 'Exports', icon: FileText },
|
||||||
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
|
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
|
||||||
|
{ path: '/scripts', label: 'Script Library', icon: Terminal },
|
||||||
{ path: '/account', label: 'Account', icon: Settings },
|
{ path: '/account', label: 'Account', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||||
@@ -83,6 +83,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" 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="/kb-accelerator" icon={Sparkles} label="KB Accelerator" collapsed />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" 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="/shares" icon={FileText} label="Exports" />
|
||||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
<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="/kb-accelerator" icon={Sparkles} label="KB Accelerator" />
|
||||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal file
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal 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 {{key}})</label>
|
||||||
|
<Input
|
||||||
|
value={param.key}
|
||||||
|
onChange={e => update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||||
|
placeholder="param_key"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
|
||||||
|
<Input
|
||||||
|
value={param.label}
|
||||||
|
onChange={e => update({ label: e.target.value })}
|
||||||
|
placeholder="Display Label"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: type + group */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Type</label>
|
||||||
|
<select
|
||||||
|
value={param.type}
|
||||||
|
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{PARAM_TYPES.map(t => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Group (optional)</label>
|
||||||
|
<Input
|
||||||
|
value={param.group ?? ''}
|
||||||
|
onChange={e => update({ group: e.target.value || null })}
|
||||||
|
placeholder="e.g. User Identity"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: placeholder + help text */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Placeholder</label>
|
||||||
|
<Input
|
||||||
|
value={param.placeholder ?? ''}
|
||||||
|
onChange={e => update({ placeholder: e.target.value || null })}
|
||||||
|
placeholder="Placeholder text"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Help text</label>
|
||||||
|
<Input
|
||||||
|
value={param.help_text ?? ''}
|
||||||
|
onChange={e => update({ help_text: e.target.value || null })}
|
||||||
|
placeholder="Help text shown below field"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: toggles */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.required}
|
||||||
|
onChange={e => update({ required: e.target.checked })}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
Required
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={param.sensitive}
|
||||||
|
onChange={e => update({ sensitive: e.target.checked })}
|
||||||
|
disabled={disabled}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
Sensitive (redacted in logs)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default value */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
|
||||||
|
<Input
|
||||||
|
value={param.default !== null && param.default !== undefined ? String(param.default) : ''}
|
||||||
|
onChange={e => update({ default: e.target.value || null })}
|
||||||
|
placeholder="Default value"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select options (only for select type) */}
|
||||||
|
{param.type === 'select' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Options</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(param.options ?? []).map((opt, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={opt.value}
|
||||||
|
onChange={e => updateOption(i, { value: e.target.value })}
|
||||||
|
placeholder="value"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={opt.label}
|
||||||
|
onChange={e => updateOption(i, { label: e.target.value })}
|
||||||
|
placeholder="label"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOption(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="p-1 text-muted-foreground hover:text-rose-500 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addOption}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation (for text/number types) */}
|
||||||
|
{(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Validation (optional)</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{param.type === 'number' ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="text-[0.625rem] text-muted-foreground">Min value</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={param.validation?.min_value ?? ''}
|
||||||
|
onChange={e => updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[0.625rem] text-muted-foreground">Max value</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={param.validation?.max_value ?? ''}
|
||||||
|
onChange={e => updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="text-[0.625rem] text-muted-foreground">Min length</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={param.validation?.min_length ?? ''}
|
||||||
|
onChange={e => updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[0.625rem] text-muted-foreground">Max length</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={param.validation?.max_length ?? ''}
|
||||||
|
onChange={e => updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="text-[0.625rem] text-muted-foreground">Pattern (regex)</label>
|
||||||
|
<Input
|
||||||
|
value={param.validation?.pattern ?? ''}
|
||||||
|
onChange={e => updateValidation({ pattern: e.target.value || undefined })}
|
||||||
|
placeholder="^[a-z]+$"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions row */}
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-border">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveUp(index)}
|
||||||
|
disabled={isFirst || disabled}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
||||||
|
>
|
||||||
|
↑ Up
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveDown(index)}
|
||||||
|
disabled={isLast || disabled}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
||||||
|
>
|
||||||
|
↓ Down
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex items-center gap-1 text-xs text-rose-500 hover:text-rose-400 transition-colors px-1.5 py-0.5"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} /> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 & Finish</>
|
||||||
|
) : (
|
||||||
|
<><ChevronRight size={13} /> Accept & Next</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal file
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
frontend/src/components/script-editor/ScriptBodyEditor.tsx
Normal file
53
frontend/src/components/script-editor/ScriptBodyEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
550
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal file
550
frontend/src/components/script-editor/ScriptTemplateEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal file
217
frontend/src/components/script-editor/ScriptTemplateListView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal file
98
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
214
frontend/src/components/scripts/ScriptConfigurePane.tsx
Normal file
214
frontend/src/components/scripts/ScriptConfigurePane.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
frontend/src/components/scripts/ScriptFilterBar.tsx
Normal file
82
frontend/src/components/scripts/ScriptFilterBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
frontend/src/components/scripts/ScriptParameterField.tsx
Normal file
156
frontend/src/components/scripts/ScriptParameterField.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
frontend/src/components/scripts/ScriptParameterForm.tsx
Normal file
70
frontend/src/components/scripts/ScriptParameterForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/components/scripts/ScriptPreview.tsx
Normal file
56
frontend/src/components/scripts/ScriptPreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
frontend/src/components/scripts/ScriptTemplateList.tsx
Normal file
69
frontend/src/components/scripts/ScriptTemplateList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/scripts/TemplateCard.tsx
Normal file
72
frontend/src/components/scripts/TemplateCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,5 +71,16 @@ export function usePermissions() {
|
|||||||
canManageCategories: hasMinimumRole(user, 'owner'),
|
canManageCategories: hasMinimumRole(user, 'owner'),
|
||||||
canManageGlobalCategories: effectiveRole === 'super_admin',
|
canManageGlobalCategories: effectiveRole === 'super_admin',
|
||||||
canManageAccount: effectiveRole === 'super_admin' || effectiveRole === 'owner',
|
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'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
300
frontend/src/lib/scriptParameterDetector.ts
Normal file
300
frontend/src/lib/scriptParameterDetector.ts
Normal 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]
|
||||||
|
}
|
||||||
98
frontend/src/pages/ScriptLibraryPage.tsx
Normal file
98
frontend/src/pages/ScriptLibraryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/pages/ScriptManagePage.tsx
Normal file
44
frontend/src/pages/ScriptManagePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
|||||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
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 AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||||
@@ -160,6 +162,8 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
|
{ path: 'analytics/me', element: page(MyAnalyticsPage) },
|
||||||
{ path: 'feedback', element: page(FeedbackPage) },
|
{ path: 'feedback', element: page(FeedbackPage) },
|
||||||
{ path: 'step-library', element: page(StepLibraryPage) },
|
{ path: 'step-library', element: page(StepLibraryPage) },
|
||||||
|
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||||||
|
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||||
{ path: 'guides', element: page(GuidesHubPage) },
|
{ path: 'guides', element: page(GuidesHubPage) },
|
||||||
|
|||||||
192
frontend/src/store/scriptGeneratorStore.ts
Normal file
192
frontend/src/store/scriptGeneratorStore.ts
Normal 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -87,3 +87,5 @@ export type {
|
|||||||
KBCommitResponse,
|
KBCommitResponse,
|
||||||
KBQuotaResponse,
|
KBQuotaResponse,
|
||||||
} from './kbAccelerator'
|
} from './kbAccelerator'
|
||||||
|
|
||||||
|
export * from './scripts'
|
||||||
|
|||||||
137
frontend/src/types/scripts.ts
Normal file
137
frontend/src/types/scripts.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user