Files
liqa/client/src/pages/scenario/ScenarioDetailPage.tsx
T
ars9 9580a7b2df fix(files): expose expiry and disable expired downloads
- return expiresAt and createdAt for scenario and run file metadata\n- keep download actions visible while preventing expired file access
2026-04-21 16:46:33 +03:00

841 lines
28 KiB
TypeScript

import { useEffect, useRef, useState, useCallback, SubmitEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '../../hooks/usePageTitle';
import {
Play,
Pencil,
Plus,
History,
Download,
Trash2,
Upload,
GripVertical,
ClipboardList,
} from 'lucide-react';
import {
environments,
scenarios,
steps,
scenarioCredentials,
scenarioFiles,
credentials as credentialsApi,
} from '../../api';
import type {
Scenario,
ScenarioStep,
ScenarioCredential,
Credential,
Environment,
FileMetadata,
} from '../../api';
import {
Breadcrumbs,
Button,
Card,
DescriptionList,
Input,
Modal,
Select,
Table,
Timestamp,
UuidBadge,
MarkdownContent,
useToast,
type TableColumn,
} from '../../ui';
import { ExportScenarioModal } from '../../components/modals';
import styles from '../Page.module.css';
export function ScenarioDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
const [loading, setLoading] = useState(true);
usePageTitle(scenario?.name ?? null);
// Credentials state
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
const [allCredentials, setAllCredentials] = useState<Credential[]>([]);
const [addCredOpen, setAddCredOpen] = useState(false);
const [addCredId, setAddCredId] = useState('');
const [addAlias, setAddAlias] = useState('');
const [addCredError, setAddCredError] = useState('');
const [addAliasError, setAddAliasError] = useState('');
const [addCredSaving, setAddCredSaving] = useState(false);
const [draggedStepId, setDraggedStepId] = useState<string | null>(null);
const [dragOverStepId, setDragOverStepId] = useState<string | null>(null);
const [reorderingSteps, setReorderingSteps] = useState(false);
const [runModalOpen, setRunModalOpen] = useState(false);
const [running, setRunning] = useState(false);
const [saveSessionFlag, setSaveSessionFlag] = useState(false);
const [envs, setEnvs] = useState<Environment[]>([]);
const [selectedEnvId, setSelectedEnvId] = useState('');
const envInitRef = useRef(false);
const [pendingDelete, setPendingDelete] = useState<
{ type: 'scenario' } | { type: 'step'; id: string } | { type: 'credential'; id: string } | null
>(null);
const [deleting, setDeleting] = useState(false);
// Export modal state
const [exportModalOpen, setExportModalOpen] = useState(false);
const [includeEnvironment, setIncludeEnvironment] = useState(true);
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
const [isExporting, setIsExporting] = useState(false);
// Files state
const [files, setFiles] = useState<FileMetadata[]>([]);
const [filesTotal, setFilesTotal] = useState(0);
const [filesLoading, setFilesLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadExpiresAt, setUploadExpiresAt] = useState('');
const [uploading, setUploading] = useState(false);
const loadFiles = useCallback(async () => {
if (!id) return;
setFilesLoading(true);
try {
const result = await scenarioFiles.list(id);
setFiles(result.items);
setFilesTotal(result.total);
} finally {
setFilesLoading(false);
}
}, [id]);
useEffect(() => {
if (!id) return;
void scenarios
.get(id)
.then((s) => {
setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []);
})
.finally(() => setLoading(false));
void loadFiles();
credentialsApi
.list(1, 200)
.then((r) => setAllCredentials(r.data))
.catch(() => {});
environments
.list(1, 200)
.then((r) => {
setEnvs(r.data);
})
.catch(() => {});
}, [id, loadFiles]);
// Default selected env to scenario's linked environment (or first env) once both are loaded
useEffect(() => {
if (envInitRef.current || envs.length === 0) return;
envInitRef.current = true;
const preferred = scenario?.environmentId ?? null;
const exists = preferred ? envs.some((e) => e.id === preferred) : false;
setSelectedEnvId(exists ? preferred! : envs[0].id);
}, [scenario, envs]);
const reloadScenario = async () => {
if (!id) return;
const s = await scenarios.get(id);
setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []);
};
const handleRun = async () => {
if (!scenario) return;
if (!selectedEnvId) {
toast.error(t('scenarios.run_modal_env_required'));
return;
}
setRunning(true);
try {
const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
toast.success(t('scenarios.run_started'));
navigate(`/scenarios/${id}/runs/${run.id}`);
setRunModalOpen(false);
setSaveSessionFlag(false);
} catch (err) {
toast.error((err as Error).message);
} finally {
setRunning(false);
}
};
const handleDelete = async () => {
if (!scenario) return;
await scenarios.remove(scenario.id);
navigate('/scenarios');
};
const handleOpenExportModal = () => {
if (!scenario) return;
// Initialise all credential checkboxes to checked
setIncludedCredentialIds(new Set(scenarioCreds.map((sc) => sc.credentialId)));
setIncludeEnvironment(true);
setExportModalOpen(true);
};
const handleExport = async () => {
if (!scenario) return;
setIsExporting(true);
try {
const credIds = Array.from(includedCredentialIds);
const yamlContent = await scenarios.exportScenario(scenario.id, {
includeEnvironment,
credentialIds: credIds,
});
const blob = new Blob([yamlContent], { type: 'application/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${scenario.name}.yaml`;
a.click();
URL.revokeObjectURL(url);
setExportModalOpen(false);
} catch (err) {
toast.error((err as Error).message);
} finally {
setIsExporting(false);
}
};
const handleFileDownload = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
};
const isFileExpired = (file: FileMetadata) =>
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
async function handleUpload(e: SubmitEvent) {
e.preventDefault();
if (!id || !uploadFile) return;
setUploading(true);
try {
await scenarioFiles.upload(
id,
uploadFile,
uploadExpiresAt ? new Date(uploadExpiresAt) : undefined,
);
setUploadOpen(false);
setUploadFile(null);
setUploadExpiresAt('');
await loadFiles();
toast.success(t('files.uploadSuccess'));
} catch {
// error toast already emitted by API client
} finally {
setUploading(false);
}
}
const handleDeleteStep = async (stepId: string) => {
await steps.remove(id!, stepId);
await reloadScenario();
};
const handleMoveStep = async (fromStepId: string, toStepId: string) => {
if (!scenario || reorderingSteps) return;
setReorderingSteps(true);
try {
const orderedSteps = [...scenario.steps].sort((a, b) => a.order - b.order);
const toIndex = orderedSteps.findIndex((step) => step.id === toStepId);
if (toIndex < 0) return;
await steps.update(id!, fromStepId, { order: toIndex });
await reloadScenario();
} finally {
setReorderingSteps(false);
}
};
const handleAddCredential = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
let valid = true;
if (!addCredId) {
setAddCredError(t('scenarios.cred_form_cred_required'));
valid = false;
}
if (!addAlias.trim()) {
setAddAliasError(t('scenarios.cred_form_alias_required'));
valid = false;
}
if (!valid) return;
setAddCredSaving(true);
try {
const sc = await scenarioCredentials.add(id!, addCredId, addAlias.trim());
// Re-fetch the credential object since the response may not include it
const full = await scenarioCredentials.list(id!);
setScenarioCreds(full);
void sc;
setAddCredOpen(false);
setAddCredId('');
setAddAlias('');
} catch (err) {
setAddCredError((err as Error).message);
} finally {
setAddCredSaving(false);
}
};
const handleRemoveCredential = async (scCredId: string) => {
await scenarioCredentials.remove(id!, scCredId);
setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId));
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
if (pendingDelete.type === 'scenario') {
await handleDelete();
return;
}
if (pendingDelete.type === 'step') {
await handleDeleteStep(pendingDelete.id);
}
if (pendingDelete.type === 'credential') {
await handleRemoveCredential(pendingDelete.id);
}
setPendingDelete(null);
} finally {
setDeleting(false);
}
};
const stepColumns: TableColumn<ScenarioStep>[] = [
{
key: 'drag',
header: '',
width: 44,
align: 'center',
render: (s) => (
<span
role="button"
tabIndex={0}
aria-label="Drag to reorder step"
className={[styles.dragHandle, reorderingSteps ? styles.dragHandleDisabled : '']
.filter(Boolean)
.join(' ')}
draggable={!reorderingSteps}
onClick={(e) => e.stopPropagation()}
onDragStart={(e) => {
if (reorderingSteps) {
e.preventDefault();
return;
}
setDraggedStepId(s.id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.id);
}}
onDragEnd={() => {
setDraggedStepId(null);
setDragOverStepId(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
}
}}
>
<GripVertical size={14} />
</span>
),
},
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
{
key: 'title',
header: t('scenarios.step_title'),
render: (s) =>
s.title ?? (
<span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>{'—'}</span>
),
},
{
key: 'updated',
header: t('scenarios.step_updated'),
width: 140,
render: (s) => <Timestamp value={s.updatedAt} />,
},
{
key: 'actions',
header: '',
width: 80,
align: 'right',
render: (s) => (
<span style={{ display: 'inline-flex', gap: 6 }}>
<Button
size="sm"
variant="secondary"
title={t('steps.action_edit')}
onClick={(e) => {
e.stopPropagation();
navigate(`/scenarios/${id}/steps/${s.id}/edit`);
}}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="danger"
title={t('steps.action_delete')}
onClick={(e) => {
e.stopPropagation();
setPendingDelete({ type: 'step', id: s.id });
}}
>
<Trash2 size={14} />
</Button>
</span>
),
},
];
const credColumns: TableColumn<ScenarioCredential>[] = [
{ key: 'alias', header: t('scenarios.cred_col_alias'), render: (sc) => sc.alias },
{
key: 'name',
header: t('scenarios.cred_col_name'),
render: (sc) => sc.credential?.name ?? `#${sc.credentialId}`,
},
{
key: 'added',
header: t('scenarios.cred_col_added'),
width: 140,
render: (sc) => <Timestamp value={sc.createdAt} />,
},
{
key: 'actions',
header: '',
width: 60,
align: 'right',
render: (sc) => (
<Button
size="sm"
variant="danger"
title={t('scenarios.cred_action_remove')}
onClick={(e) => {
e.stopPropagation();
setPendingDelete({ type: 'credential', id: sc.id });
}}
>
<Trash2 size={14} />
</Button>
),
},
];
return (
<div>
<div className={styles.pageToolbar}>
<Breadcrumbs
items={[
{
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ label: scenario?.name ?? `#${id}` },
]}
/>
{scenario && (
<div className={styles.toolbarActions}>
<Button size="sm" onClick={() => setRunModalOpen(true)}>
<Play size={14} />
{t('scenarios.action_run')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/runs`)}>
<History size={14} />
{t('scenarios.action_runs')}
</Button>
<Button variant="secondary" size="sm" onClick={handleOpenExportModal}>
<Upload size={14} />
{t('scenarios.action_export')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
<Pencil size={14} />
{t('scenarios.action_edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setPendingDelete({ type: 'scenario' })}
>
<Trash2 size={14} />
{t('scenarios.action_delete')}
</Button>
</div>
)}
</div>
{loading && <p className={styles.muted}>{t('scenarios.loading')}</p>}
{scenario && (
<>
<Card>
<DescriptionList
layout="grid"
items={[
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name },
...(scenario.environment
? [
{
term: t('scenarios.field_environment'),
detail: (
<a
href={`/environments/${scenario.environment.id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/environments/${scenario.environment!.id}`);
}}
>
{scenario.environment.name}
</a>
),
},
]
: []),
{
term: t('scenarios.field_created'),
detail: <Timestamp value={scenario.createdAt} />,
},
{
term: t('scenarios.field_updated'),
detail: <Timestamp value={scenario.updatedAt} />,
},
]}
/>
</Card>
{scenario.description && (
<div className={styles.stepsSection}>
<div className={styles.sectionHeadingRow}>
<h2 className={styles.sectionHeading}>{t('scenarios.field_description')}</h2>
</div>
<Card>
<MarkdownContent content={scenario.description} />
</Card>
</div>
)}
{scenario.steps.length > 0 && (
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('scenarios.steps_heading')}</h2>
<Button size="sm" onClick={() => navigate(`/scenarios/${id}/steps/new`)}>
<Plus size={14} />
{t('steps.action_add')}
</Button>
</div>
<Table
columns={stepColumns}
data={scenario.steps}
rowKey={(s) => s.id}
loading={false}
emptyMessage=""
getRowProps={(s) => ({
className: dragOverStepId === s.id ? styles.dragOverRow : undefined,
onDragOver: (e) => {
if (reorderingSteps) return;
e.preventDefault();
if (draggedStepId && draggedStepId !== s.id) {
setDragOverStepId(s.id);
}
},
onDragLeave: () => {
if (dragOverStepId === s.id) {
setDragOverStepId(null);
}
},
onDrop: async (e) => {
if (reorderingSteps) return;
e.preventDefault();
const fromId = draggedStepId ?? e.dataTransfer.getData('text/plain');
setDragOverStepId(null);
setDraggedStepId(null);
if (!fromId || fromId === s.id) return;
await handleMoveStep(fromId, s.id);
},
})}
/>
</div>
)}
{scenario.steps.length === 0 && (
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('scenarios.steps_heading')}</h2>
<Button size="sm" onClick={() => navigate(`/scenarios/${id}/steps/new`)}>
<Plus size={14} />
{t('steps.action_add')}
</Button>
</div>
</div>
)}
{/* ── Credentials section ──────────────────────────────────── */}
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('scenarios.credentials_heading')}</h2>
<Button size="sm" onClick={() => setAddCredOpen((v) => !v)}>
<Plus size={14} />
{t('scenarios.cred_action_add')}
</Button>
</div>
{addCredOpen && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<Card className={styles.formCard}>
<form onSubmit={handleAddCredential} noValidate>
<div className={styles.formFields}>
<Select
label={t('scenarios.cred_form_cred')}
value={addCredId}
onChange={(e) => {
setAddCredId(e.target.value);
setAddCredError('');
}}
error={addCredError || undefined}
options={[
{ value: '', label: t('scenarios.cred_form_cred_placeholder') },
...allCredentials.map((c) => ({ value: String(c.id), label: c.name })),
]}
/>
<Input
label={t('scenarios.cred_form_alias')}
placeholder={t('scenarios.cred_form_alias_placeholder')}
value={addAlias}
onChange={(e) => {
setAddAlias(e.target.value);
setAddAliasError('');
}}
error={addAliasError || undefined}
/>
</div>
<div className={styles.formActions}>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
setAddCredOpen(false);
setAddCredId('');
setAddAlias('');
}}
>
{t('scenarios.cred_action_cancel')}
</Button>
<Button type="submit" size="sm" loading={addCredSaving}>
{t('scenarios.cred_action_save')}
</Button>
</div>
</form>
</Card>
</div>
)}
{scenarioCreds.length > 0 ? (
<Table
columns={credColumns}
data={scenarioCreds}
rowKey={(sc) => sc.id}
loading={false}
emptyMessage=""
/>
) : (
<p className={styles.muted}>{t('scenarios.cred_empty')}</p>
)}
</div>
{/* ── Files section ───────────────────────────────────────────────── */}
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('files.title')}</h2>
<Button size="sm" onClick={() => setUploadOpen(true)}>
<Upload size={14} /> {t('files.upload')}
</Button>
</div>
<Table<FileMetadata>
loading={filesLoading}
data={files}
rowKey={(f) => f.id}
columns={
[
{
key: 'name',
header: t('files.name'),
render: (f) => f.name,
},
{
key: 'mimeType',
header: t('files.mimeType'),
render: (f) => f.mimeType,
},
{
key: 'size',
header: t('files.size'),
render: (f) => `${(f.size / 1024).toFixed(1)} KB`,
},
{
key: 'expiresAt',
header: t('files.expiresAt'),
render: (f) => (f.expiresAt ? <Timestamp value={f.expiresAt} /> : '—'),
},
{
key: 'createdAt',
header: t('files.createdAt'),
render: (f) => <Timestamp value={f.createdAt} />,
},
{
key: 'download',
header: '',
render: (f) => {
const expired = isFileExpired(f);
const tooltip = expired ? t('files.expiredTooltip') : t('files.download');
return (
<span title={tooltip}>
<Button
variant="secondary"
size="sm"
title={tooltip}
aria-label={tooltip}
disabled={expired}
onClick={() =>
handleFileDownload(scenarioFiles.contentUrl(id!, f.id), f.name)
}
>
<Download size={14} />
</Button>
</span>
);
},
},
] satisfies TableColumn<FileMetadata>[]
}
emptyMessage={t('files.empty')}
/>
</div>
{/* ── Upload modal ─────────────────────────────────────────────── */}
<Modal
open={uploadOpen}
title={t('files.uploadTitle')}
onClose={() => setUploadOpen(false)}
>
<form onSubmit={handleUpload}>
<input
type="file"
required
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
/>
<Input
label={t('files.expiresAtLabel')}
type="datetime-local"
value={uploadExpiresAt}
onChange={(e) => setUploadExpiresAt(e.target.value)}
/>
<Button type="submit" loading={uploading} disabled={!uploadFile}>
{t('files.upload')}
</Button>
</form>
</Modal>
</>
)}
<Modal
open={pendingDelete != null}
title={
pendingDelete?.type === 'step'
? t('steps.action_delete')
: pendingDelete?.type === 'credential'
? t('scenarios.cred_action_remove')
: t('scenarios.action_delete')
}
onClose={() => !deleting && setPendingDelete(null)}
footer={
<>
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
{t('common.button_cancel')}
</Button>
<Button variant="danger" onClick={confirmDelete} disabled={deleting}>
{t('common.button_confirm')}
</Button>
</>
}
>
{pendingDelete?.type === 'step' && t('common.confirm_delete_step')}
{pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')}
{pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')}
</Modal>
<Modal
open={runModalOpen}
title={t('scenarios.run_modal_title')}
onClose={() => !running && setRunModalOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
{t('common.button_cancel')}
</Button>
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
{t('scenarios.action_run')}
</Button>
</>
}
>
{envs.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p>
) : (
<>
<Select
label={t('scenarios.run_modal_env_label')}
value={selectedEnvId}
onChange={(e) => setSelectedEnvId(e.target.value)}
options={envs.map((env) => ({ value: env.id, label: env.name }))}
/>
<label
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
>
<input
type="checkbox"
checked={saveSessionFlag}
onChange={(e) => setSaveSessionFlag(e.target.checked)}
/>
<span>{t('common.save_session')}</span>
</label>
</>
)}
</Modal>
<ExportScenarioModal
open={exportModalOpen}
scenario={scenario}
scenarioCredentials={scenarioCreds}
environment={
scenario?.environment ?? envs.find((e) => e.id === scenario?.environmentId) ?? null
}
includeEnvironment={includeEnvironment}
onIncludeEnvironmentChange={setIncludeEnvironment}
includedCredentialIds={includedCredentialIds}
onCredentialToggle={(credId, checked) => {
setIncludedCredentialIds((prev) => {
const next = new Set(prev);
if (checked) next.add(credId);
else next.delete(credId);
return next;
});
}}
onClose={() => setExportModalOpen(false)}
onExport={handleExport}
isExporting={isExporting}
/>
</div>
);
}