diff --git a/client/src/App.tsx b/client/src/App.tsx index da9d2a0..7a5e208 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Globe, KeyRound, Monitor, ClipboardList, Activity } from 'lucide-react'; +import { Globe, KeyRound, Monitor, ClipboardList, Activity, KeySquare } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import styles from './App.module.css'; import { SidePanel, ThemeSwitcher } from './ui'; @@ -21,9 +21,14 @@ import { EditStepPage } from './pages/scenario/EditStepPage'; import { RunsPage } from './pages/run/RunsPage'; import { RunDetailPage } from './pages/run/RunDetailPage'; import { AllRunsPage } from './pages/run/AllRunsPage'; +import { CredentialsPage } from './pages/credential/CredentialsPage'; +import { CreateCredentialPage } from './pages/credential/CreateCredentialPage'; +import { EditCredentialPage } from './pages/credential/EditCredentialPage'; +import { CredentialDetailPage } from './pages/credential/CredentialDetailPage'; const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [ { path: '/environments', labelKey: 'nav.environments', Icon: Globe }, + { path: '/credentials', labelKey: 'nav.credentials', Icon: KeySquare }, { path: '/keys', labelKey: 'nav.keys', Icon: KeyRound }, { path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor }, { path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList }, @@ -78,6 +83,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4051308..ce08573 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,7 +1,9 @@ import type { PaginatedResponse, + Credential, Environment, KeysResponse, + ScenarioCredential, Session, Scenario, ScenarioRun, @@ -30,6 +32,32 @@ async function request(path: string, init?: RequestInit): Promise { return JSON.parse(text) as T; } +// ── Credentials ─────────────────────────────────────────────────────────────── + +export const credentials = { + list(page = 1, limit = 50): Promise> { + return request(`/credentials?page=${page}&limit=${limit}`); + }, + get(id: number): Promise { + return request(`/credentials/${id}`); + }, + create(name: string, data?: string): Promise { + return request('/credentials', { + method: 'POST', + body: JSON.stringify({ name, data }), + }); + }, + update(id: number, patch: Partial>): Promise { + return request(`/credentials/${id}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + }, + remove(id: number): Promise { + return request(`/credentials/${id}`, { method: 'DELETE' }); + }, +}; + // ── Environments ────────────────────────────────────────────────────────────── export const environments = { @@ -132,9 +160,28 @@ export const runs = { }, }; -// ── Scenario Steps ──────────────────────────────────────────────────────────── +// ── Scenario Credentials ────────────────────────────────────────────────────── +export const scenarioCredentials = { + list(scenarioId: number): Promise { + return request(`/scenarios/${scenarioId}/credentials`); + }, + add(scenarioId: number, credentialId: number, alias: string): Promise { + return request(`/scenarios/${scenarioId}/credentials`, { + method: 'POST', + body: JSON.stringify({ credentialId, alias }), + }); + }, + remove(scenarioId: number, scCredId: number): Promise { + return request(`/scenarios/${scenarioId}/credentials/${scCredId}`, { + method: 'DELETE', + }); + }, +}; + +// ── Scenario Steps ──────────────────────────────────────────────────────────── export interface CreateStepPayload { + title?: string; order: number; type: 'login' | 'exec' | 'sign'; sessionName: string; @@ -143,6 +190,7 @@ export interface CreateStepPayload { } export interface UpdateStepPayload { + title?: string; order?: number; type?: 'login' | 'exec' | 'sign'; sessionName?: string; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 565f833..c247e1d 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -24,6 +24,17 @@ export interface Environment { updatedAt: string; } +// ── Credentials ─────────────────────────────────────────────────────────────── + +export interface Credential { + id: number; + name: string; + data: string | null; + lastUsedAt: string | null; + createdAt: string; + updatedAt: string; +} + // ── Keys ────────────────────────────────────────────────────────────────────── export interface KeysResponse { @@ -53,6 +64,7 @@ export interface ScenarioStep { scenarioId: number; order: number; type: StepType; + title: string | null; sessionName: string; execCode: string | null; validateCode: string | null; @@ -60,10 +72,20 @@ export interface ScenarioStep { updatedAt: string; } +export interface ScenarioCredential { + id: number; + scenarioId: number; + credentialId: number; + alias: string; + credential: Credential; + createdAt: string; +} + export interface Scenario { id: number; name: string; steps?: ScenarioStep[]; + scenarioCredentials?: ScenarioCredential[]; createdAt: string; updatedAt: string; } diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 1532c02..39e03db 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -3,11 +3,13 @@ "not_found": "Not found", "not_found_session": "Session {{id}} does not exist or has been deleted.", "not_found_environment": "Environment {{id}} does not exist or has been deleted.", + "not_found_credential": "Credential {{id}} does not exist or has been deleted.", "not_found_scenario": "Scenario {{id}} does not exist or has been deleted.", "generic": "Something went wrong. Please try again." }, "nav": { "environments": "Environments", + "credentials": "Credentials", "keys": "Keys", "sessions": "Sessions", "scenarios": "Scenarios", @@ -44,8 +46,32 @@ "field_updated": "Updated", "section_urls": "URLs" }, + "credentials": { + "title": "Credentials", + "empty": "No credentials yet.", + "loading": "Loading…", + "menu_label": "Credential options", + "action_edit": "Edit", + "action_delete": "Delete", + "action_add": "Add credential", + "action_save": "Create", + "action_update": "Save changes", + "action_cancel": "Cancel", + "create_title": "New Credential", + "edit_title": "Edit Credential", + "form_name": "Name", + "form_name_placeholder": "e.g. staging-api-key", + "form_name_required": "Name is required", + "form_data": "Data (JSON)", + "form_data_placeholder": "{\"token\": \"…\"}", + "form_data_invalid_json": "Must be valid JSON", + "field_id": "ID", + "field_last_used": "Last Used", + "field_created": "Created", + "field_updated": "Updated", + "section_data": "Data" + }, "keys": { - "title": "Keys", "col_name": "Key name", "empty": "No key files found." }, @@ -81,6 +107,7 @@ "field_updated": "Updated", "steps_heading": "Steps", "step_order": "#", + "step_title": "Title", "step_type": "Type", "step_session": "Session", "step_updated": "Updated", @@ -94,7 +121,22 @@ "edit_title": "Edit Scenario", "form_name": "Name", "form_name_placeholder": "e.g. Login flow", - "form_name_required": "Name is required" + "form_name_required": "Name is required", + "credentials_heading": "Credentials", + "cred_empty": "No credentials assigned.", + "cred_col_alias": "Alias", + "cred_col_name": "Credential", + "cred_col_added": "Added", + "cred_action_add": "Add credential", + "cred_action_remove": "Remove", + "cred_action_save": "Add", + "cred_action_cancel": "Cancel", + "cred_form_cred": "Credential", + "cred_form_cred_placeholder": "Select credential…", + "cred_form_cred_required": "Select a credential", + "cred_form_alias": "Alias", + "cred_form_alias_placeholder": "e.g. api_key", + "cred_form_alias_required": "Alias is required" }, "theme": { "switch_to_light": "Switch to light theme", @@ -121,6 +163,8 @@ "action_cancel": "Cancel", "form_order": "Order", "form_order_invalid": "Order must be a non-negative integer", + "form_title": "Title", + "form_title_placeholder": "e.g. Check login page", "form_type": "Type", "form_session": "Session name", "form_session_placeholder": "e.g. my-session", diff --git a/client/src/pages/Page.module.css b/client/src/pages/Page.module.css index d0519a8..742003c 100644 --- a/client/src/pages/Page.module.css +++ b/client/src/pages/Page.module.css @@ -6,14 +6,15 @@ } .sectionHeading { - margin: var(--space-6) 0 var(--space-3); + margin: 0; + line-height: 1; font-size: var(--font-size-md); font-weight: 600; color: var(--color-text); } .stepsSection { - margin-top: var(--space-2); + margin-top: var(--space-6); } .breadcrumbs { @@ -311,3 +312,15 @@ .envDetailUrlVal a:hover { text-decoration: underline; } + +/* ── Code block ────────────────────────────────────────────────────────── */ + +.codeBlock { + margin: 0; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--font-size-sm); + color: var(--color-text); + white-space: pre-wrap; + word-break: break-all; + overflow-x: auto; +} diff --git a/client/src/pages/credential/CreateCredentialPage.tsx b/client/src/pages/credential/CreateCredentialPage.tsx new file mode 100644 index 0000000..2e56606 --- /dev/null +++ b/client/src/pages/credential/CreateCredentialPage.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { credentials } from '../../api'; +import { Breadcrumbs, Button, Card, Input, Textarea } from '../../ui'; +import styles from '../Page.module.css'; + +export function CreateCredentialPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [name, setName] = useState(''); + const [data, setData] = useState(''); + const [nameError, setNameError] = useState(''); + const [dataError, setDataError] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + let valid = true; + if (!name.trim()) { + setNameError(t('credentials.form_name_required')); + valid = false; + } + if (data.trim()) { + try { + JSON.parse(data.trim()); + } catch { + setDataError(t('credentials.form_data_invalid_json')); + valid = false; + } + } + if (!valid) return; + setSaving(true); + setError(null); + try { + const credential = await credentials.create(name.trim(), data.trim() || undefined); + navigate(`/credentials/${credential.id}`); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ navigate('/credentials') }, + { label: t('credentials.create_title') }, + ]} + /> +
+ + {error &&

{error}

} + +
+ +
+ { + setName(e.target.value); + setNameError(''); + }} + error={nameError || undefined} + required + autoFocus + /> +