diff --git a/client/src/App.tsx b/client/src/App.tsx index c659af2..7dfe541 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,15 +5,19 @@ import type { LucideIcon } from 'lucide-react'; import styles from './App.module.css'; import { SidePanel, ThemeSwitcher } from './ui'; import liqaLogo from './assets/liqa.svg'; -import { EnvironmentsPage } from './pages/EnvironmentsPage'; -import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage'; -import { CreateEnvironmentPage } from './pages/CreateEnvironmentPage'; -import { EditEnvironmentPage } from './pages/EditEnvironmentPage'; +import { EnvironmentsPage } from './pages/environment/EnvironmentsPage'; +import { EnvironmentDetailPage } from './pages/environment/EnvironmentDetailPage'; +import { CreateEnvironmentPage } from './pages/environment/CreateEnvironmentPage'; +import { EditEnvironmentPage } from './pages/environment/EditEnvironmentPage'; import { KeysPage } from './pages/KeysPage'; -import { SessionsPage } from './pages/SessionsPage'; -import { SessionDetailPage } from './pages/SessionDetailPage'; -import { ScenariosPage } from './pages/ScenariosPage'; -import { ScenarioDetailPage } from './pages/ScenarioDetailPage'; +import { SessionsPage } from './pages/session/SessionsPage'; +import { SessionDetailPage } from './pages/session/SessionDetailPage'; +import { ScenariosPage } from './pages/scenario/ScenariosPage'; +import { ScenarioDetailPage } from './pages/scenario/ScenarioDetailPage'; +import { CreateScenarioPage } from './pages/scenario/CreateScenarioPage'; +import { EditScenarioPage } from './pages/scenario/EditScenarioPage'; +import { CreateStepPage } from './pages/scenario/CreateStepPage'; +import { EditStepPage } from './pages/scenario/EditStepPage'; const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [ { path: '/environments', labelKey: 'nav.environments', Icon: Globe }, @@ -63,6 +67,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4c0b3fd..1efb22e 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -113,3 +113,42 @@ export const scenarios = { return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); }, }; + +// ── Scenario Steps ──────────────────────────────────────────────────────────── + +export interface CreateStepPayload { + order: number; + type: 'login' | 'exec' | 'sign'; + sessionName: string; + execCode?: string; + validateCode?: string; +} + +export interface UpdateStepPayload { + order?: number; + type?: 'login' | 'exec' | 'sign'; + sessionName?: string; + execCode?: string; + validateCode?: string; +} + +export const steps = { + get(scenarioId: number, stepId: number): Promise { + return request(`/scenarios/${scenarioId}/steps/${stepId}`); + }, + create(scenarioId: number, payload: CreateStepPayload): Promise { + return request(`/scenarios/${scenarioId}/steps`, { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + update(scenarioId: number, stepId: number, payload: UpdateStepPayload): Promise { + return request(`/scenarios/${scenarioId}/steps/${stepId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + }, + remove(scenarioId: number, stepId: number): Promise { + return request(`/scenarios/${scenarioId}/steps/${stepId}`, { method: 'DELETE' }); + }, +}; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 8828468..7353a45 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -65,8 +65,7 @@ "field_updated": "Updated", "field_lastUsed": "Last Used" }, - "scenarios": { - "title": "Scenarios", + "scenarios": { "title": "Scenarios", "col_id": "ID", "col_name": "Name", "col_updated": "Updated", @@ -83,7 +82,17 @@ "step_order": "#", "step_type": "Type", "step_session": "Session", - "step_updated": "Updated" + "step_updated": "Updated", + "action_add": "New scenario", + "action_edit": "Edit", + "action_save": "Create", + "action_update": "Save changes", + "action_cancel": "Cancel", + "create_title": "New Scenario", + "edit_title": "Edit Scenario", + "form_name": "Name", + "form_name_placeholder": "e.g. Login flow", + "form_name_required": "Name is required" }, "theme": { "switch_to_light": "Switch to light theme", @@ -97,5 +106,26 @@ "range": "{{from}}–{{to}} of {{total}}", "per_page": "{{count}} per page", "page_size": "Items per page" + }, + "steps": { + "create_title": "Add Step", + "edit_title": "Edit Step #{{order}}", + "loading": "Loading…", + "action_add": "Add step", + "action_edit": "Edit", + "action_delete": "Delete", + "action_save": "Add step", + "action_update": "Save changes", + "action_cancel": "Cancel", + "form_order": "Order", + "form_order_invalid": "Order must be a non-negative integer", + "form_type": "Type", + "form_session": "Session name", + "form_session_placeholder": "e.g. my-session", + "form_session_required": "Session name is required", + "form_exec_code": "Exec code", + "form_exec_code_placeholder": "return await page.title();", + "form_validate_code": "Validate code", + "form_validate_code_placeholder": "return { success: result !== null, description: \"…\" };" } } diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index a2b9f1e..96e6cd8 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -183,6 +183,47 @@ justify-content: flex-end; } +.formField { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.fieldLabel { + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-text); +} + +.textarea { + width: 100%; + padding: var(--space-2) var(--space-3); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--font-size-sm); + color: var(--color-text); + background: var(--color-bg-input, var(--color-bg-raised)); + border: var(--border-width) solid var(--color-border); + border-radius: var(--radius-md); + resize: vertical; + transition: border-color var(--transition), box-shadow var(--transition); + box-sizing: border-box; +} + +.textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent); +} + +/* ── Section toolbar ─────────────────────────────────────────────────────── */ + +.sectionToolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-3); +} + /* ── Environment detail page ────────────────────────────────────────────── */ .detailToolbar { diff --git a/client/src/pages/CreateEnvironmentPage.tsx b/client/src/pages/environment/CreateEnvironmentPage.tsx similarity index 95% rename from client/src/pages/CreateEnvironmentPage.tsx rename to client/src/pages/environment/CreateEnvironmentPage.tsx index ff9947e..425810f 100644 --- a/client/src/pages/CreateEnvironmentPage.tsx +++ b/client/src/pages/environment/CreateEnvironmentPage.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { environments } from '../api'; -import { Breadcrumbs, Button, Card, Input } from '../ui'; -import styles from './Page.module.css'; +import { environments } from '../../api'; +import { Breadcrumbs, Button, Card, Input } from '../../ui'; +import styles from '../Page.module.css'; export function CreateEnvironmentPage() { const { t } = useTranslation(); diff --git a/client/src/pages/EditEnvironmentPage.tsx b/client/src/pages/environment/EditEnvironmentPage.tsx similarity index 95% rename from client/src/pages/EditEnvironmentPage.tsx rename to client/src/pages/environment/EditEnvironmentPage.tsx index eee8eb9..782d09d 100644 --- a/client/src/pages/EditEnvironmentPage.tsx +++ b/client/src/pages/environment/EditEnvironmentPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { environments } from '../api'; -import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, Input } from '../ui'; -import styles from './Page.module.css'; +import { environments } from '../../api'; +import type { Environment } from '../../api'; +import { Breadcrumbs, Button, Card, Input } from '../../ui'; +import styles from '../Page.module.css'; export function EditEnvironmentPage() { const { t } = useTranslation(); diff --git a/client/src/pages/EnvironmentDetailPage.tsx b/client/src/pages/environment/EnvironmentDetailPage.tsx similarity index 95% rename from client/src/pages/EnvironmentDetailPage.tsx rename to client/src/pages/environment/EnvironmentDetailPage.tsx index 95fb7e2..de0d64d 100644 --- a/client/src/pages/EnvironmentDetailPage.tsx +++ b/client/src/pages/environment/EnvironmentDetailPage.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Pencil, Trash2 } from 'lucide-react'; -import { environments } from '../api'; -import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../ui'; -import styles from './Page.module.css'; +import { environments } from '../../api'; +import type { Environment } from '../../api'; +import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp } from '../../ui'; +import styles from '../Page.module.css'; export function EnvironmentDetailPage() { const { t } = useTranslation(); diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/environment/EnvironmentsPage.tsx similarity index 95% rename from client/src/pages/EnvironmentsPage.tsx rename to client/src/pages/environment/EnvironmentsPage.tsx index b52aab1..d378973 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/environment/EnvironmentsPage.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react'; -import { environments } from '../api'; -import type { Environment } from '../api'; -import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../ui'; -import styles from './Page.module.css'; +import { environments } from '../../api'; +import type { Environment } from '../../api'; +import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp } from '../../ui'; +import styles from '../Page.module.css'; function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: number) => void }) { const { t } = useTranslation(); diff --git a/client/src/pages/scenario/CreateScenarioPage.tsx b/client/src/pages/scenario/CreateScenarioPage.tsx new file mode 100644 index 0000000..458b6ef --- /dev/null +++ b/client/src/pages/scenario/CreateScenarioPage.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { scenarios } from '../../api'; +import { Breadcrumbs, Button, Card, Input } from '../../ui'; +import styles from '../Page.module.css'; + +export function CreateScenarioPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(''); + const [nameError, setNameError] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + setNameError(t('scenarios.form_name_required')); + return; + } + setSaving(true); + setError(null); + try { + const scenario = await scenarios.create(name.trim()); + navigate(`/scenarios/${scenario.id}`); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ navigate('/scenarios') }, + { label: t('scenarios.create_title') }, + ]} + /> +
+ + {error &&

{error}

} + +
+ +
+ { + setName(e.target.value); + setNameError(''); + }} + error={nameError || undefined} + required + autoFocus + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx new file mode 100644 index 0000000..0272714 --- /dev/null +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { scenarios, steps } from '../../api'; +import type { Scenario, StepType } from '../../api'; +import type { CreateStepPayload } from '../../api/client'; +import { Breadcrumbs, Button, Card, Input, Select } from '../../ui'; +import styles from '../Page.module.css'; + +const STEP_TYPES: StepType[] = ['login', 'exec', 'sign']; + +export function CreateStepPage() { + const { t } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [scenario, setScenario] = useState(null); + const [order, setOrder] = useState('0'); + const [type, setType] = useState('exec'); + const [sessionName, setSessionName] = useState(''); + const [execCode, setExecCode] = useState(''); + const [validateCode, setValidateCode] = useState(''); + const [errors, setErrors] = useState>>({}); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) return; + scenarios.get(Number(id)).then(setScenario).catch(() => null); + }, [id]); + + const validate = (): boolean => { + const next: typeof errors = {}; + if (!sessionName.trim()) next.sessionName = t('steps.form_session_required'); + if (isNaN(Number(order)) || Number(order) < 0) next.order = t('steps.form_order_invalid'); + setErrors(next); + return Object.keys(next).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + setError(null); + try { + await steps.create(Number(id), { + order: Number(order), + type, + sessionName: sessionName.trim(), + execCode: execCode.trim() || undefined, + validateCode: validateCode.trim() || undefined, + }); + navigate(`/scenarios/${id}`); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ navigate('/scenarios') }, + { + label: scenario?.name ?? `#${id}`, + onClick: () => navigate(`/scenarios/${id}`), + }, + { label: t('steps.create_title') }, + ]} + /> +
+ + {error &&

{error}

} + +
+ +
+ setOrder(e.target.value)} + error={errors.order} + /> + { + setSessionName(e.target.value); + setErrors((prev) => ({ ...prev, sessionName: undefined })); + }} + error={errors.sessionName} + required + /> +
+ +