feat(frontend): full FR i18n via react-i18next + 2-tab engagement detail (sprint 12) #14

Merged
knacky merged 14 commits from sprint/12-i18n-fr into main 2026-06-22 08:25:51 +00:00
10 changed files with 117 additions and 89 deletions
Showing only changes of commit ad0a3f5cac - Show all commits

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types'; import type { C2Callback } from '@/api/types';
@@ -20,20 +21,22 @@ export function C2CallbackPicker({
onSelect, onSelect,
rowTestId = 'c2-callback-row', rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element { }: C2CallbackPickerProps): JSX.Element {
const { t } = useTranslation();
if (isLoading) { if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>; return <p className="text-[14px] text-graphite">{t('state.loading')}</p>;
} }
if (isError) { if (isError) {
return ( return (
<p className="text-[14px] text-bloom-deep"> <p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')} {extractApiError(error, t('state.error.desc'))}
</p> </p>
); );
} }
if (callbacks.length === 0) { if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>; return <p className="text-[14px] text-graphite">{t('c2.modal.picker.empty')}</p>;
} }
return ( return (
@@ -41,12 +44,12 @@ export function C2CallbackPicker({
<table className="w-full text-[14px]"> <table className="w-full text-[14px]">
<thead> <thead>
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.displayId')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.active')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.host')}</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.user')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.domain')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.picker.col.lastCheckin')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -78,7 +81,7 @@ export function C2CallbackPicker({
: 'bg-cloud text-graphite border border-hairline' : 'bg-cloud text-graphite border border-hairline'
}`} }`}
> >
{cb.active ? 'Active' : 'Inactive'} {cb.active ? t('c2.modal.picker.status.active') : t('c2.modal.picker.status.inactive')}
</span> </span>
</td> </td>
<td className="px-md py-sm font-mono">{cb.host}</td> <td className="px-md py-sm font-mono">{cb.host}</td>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react'; import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2'; import { useC2Config, useDeleteC2Config, useTestC2Config, useUpdateC2Config } from '@/hooks/useC2';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
@@ -10,6 +11,7 @@ interface C2ConfigCardProps {
} }
export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element { export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const configQuery = useC2Config(engagementId); const configQuery = useC2Config(engagementId);
@@ -55,11 +57,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
try { try {
await updateMutation.mutateAsync(input); await updateMutation.mutateAsync(input);
push('C2 configuration saved', 'success'); push(t('c2.config.toast.saved'), 'success');
setToken(''); setToken('');
setReplaceToken(false); setReplaceToken(false);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not save C2 configuration'), 'error'); push(extractApiError(err, t('c2.config.error.save')), 'error');
} }
}; };
@@ -68,13 +70,13 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
setTestResult(null); setTestResult(null);
try { try {
await deleteMutation.mutateAsync(); await deleteMutation.mutateAsync();
push('C2 configuration removed', 'success'); push(t('c2.config.toast.deleted'), 'success');
setUrl(''); setUrl('');
setToken(''); setToken('');
setVerifyTls(false); setVerifyTls(false);
setReplaceToken(false); setReplaceToken(false);
} catch (err) { } catch (err) {
push(extractApiError(err, 'Could not remove C2 configuration'), 'error'); push(extractApiError(err, t('c2.config.error.delete')), 'error');
} }
}; };
@@ -84,10 +86,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
const result = await testMutation.mutateAsync(); const result = await testMutation.mutateAsync();
setTestResult({ setTestResult({
ok: result.ok, ok: result.ok,
message: result.ok ? 'Connected' : (result.error ?? 'Connection failed'), message: result.ok ? t('c2.config.connected') : (result.error ?? t('c2.config.connectionFailed')),
}); });
} catch (err) { } catch (err) {
setTestResult({ ok: false, message: extractApiError(err, 'Test failed') }); setTestResult({ ok: false, message: extractApiError(err, t('c2.config.connectionFailed')) });
} }
}; };
@@ -98,25 +100,25 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
data-testid="c2-config-card" data-testid="c2-config-card"
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<h2 className="text-[20px] font-medium text-ink">C2 configuration</h2> <h2 className="text-[20px] font-medium text-ink">{t('c2.config.title')}</h2>
{is503 && ( {is503 && (
<div <div
role="alert" role="alert"
className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal" className="rounded-none px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
> >
C2 features are disabled (server has no encryption key configured). {t('c2.config.disabled')}
</div> </div>
)} )}
{configQuery.isLoading ? ( {configQuery.isLoading ? (
<p className="text-[14px] text-graphite">Loading</p> <p className="text-[14px] text-graphite">{t('c2.config.loading')}</p>
) : ( ) : (
<form onSubmit={onSave} noValidate className="flex flex-col gap-md"> <form onSubmit={onSave} noValidate className="flex flex-col gap-md">
<FormField <FormField
label="URL" label={t('c2.config.field.url')}
htmlFor="c2-url" htmlFor="c2-url"
hint="HTTPS required (e.g. https://mythic.lab:7443)" hint={t('c2.config.field.urlHint')}
> >
<TextInput <TextInput
id="c2-url" id="c2-url"
@@ -130,7 +132,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
/> />
</FormField> </FormField>
<FormField label="API token" htmlFor="c2-token"> <FormField label={t('c2.config.field.token')} htmlFor="c2-token">
{config?.has_token && !replaceToken ? ( {config?.has_token && !replaceToken ? (
<div className="flex items-center gap-md"> <div className="flex items-center gap-md">
<TextInput <TextInput
@@ -149,7 +151,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={() => setReplaceToken(true)} onClick={() => setReplaceToken(true)}
disabled={disabled} disabled={disabled}
> >
Replace token {t('c2.config.btn.replaceToken')}
</button> </button>
</div> </div>
) : ( ) : (
@@ -158,7 +160,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
data-testid="c2-token-input" data-testid="c2-token-input"
type="password" type="password"
name="api_token" name="api_token"
placeholder={config?.has_token ? 'Enter new token' : 'API token'} placeholder={config?.has_token ? t('c2.config.field.tokenHint') : t('c2.config.field.token')}
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
disabled={disabled} disabled={disabled}
@@ -178,12 +180,11 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
className="h-4 w-4 accent-primary" className="h-4 w-4 accent-primary"
/> />
<label htmlFor="c2-verify-tls" className="text-[14px] text-ink"> <label htmlFor="c2-verify-tls" className="text-[14px] text-ink">
Verify TLS certificate {t('c2.config.field.verifyTls')}
</label> </label>
</div> </div>
<p className="text-[12px] text-graphite"> <p className="text-[12px] text-graphite">
Uncheck only for lab Mythic with self-signed certificates. Disabling {t('c2.config.field.verifyTlsHint')}
verification exposes the API token to MITM.
</p> </p>
</div> </div>
@@ -194,7 +195,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
className="btn-primary" className="btn-primary"
disabled={disabled || submitting} disabled={disabled || submitting}
> >
{updateMutation.isPending ? 'Saving' : 'Save'} {updateMutation.isPending ? t('c2.config.btn.saving') : t('c2.config.btn.save')}
</button> </button>
<button <button
@@ -204,7 +205,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={onTest} onClick={onTest}
disabled={disabled || testMutation.isPending || !config} disabled={disabled || testMutation.isPending || !config}
> >
{testMutation.isPending ? 'Testing' : 'Test connection'} {testMutation.isPending ? t('c2.config.btn.testing') : t('c2.config.btn.test')}
</button> </button>
{testResult !== null && ( {testResult !== null && (
@@ -223,7 +224,7 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={disabled || submitting} disabled={disabled || submitting}
> >
Delete configuration {t('c2.config.btn.delete')}
</button> </button>
)} )}
</div> </div>
@@ -232,10 +233,10 @@ export function C2ConfigCard({ engagementId }: C2ConfigCardProps): JSX.Element {
{showDeleteConfirm && ( {showDeleteConfirm && (
<ConfirmDialog <ConfirmDialog
title="Delete C2 configuration" title={t('c2.config.deleteConfirm.title')}
description="This will remove the C2 configuration for this engagement. The API token will be permanently deleted." description={t('c2.config.deleteConfirm.desc')}
confirmLabel="Delete" confirmLabel={t('c2.config.deleteConfirm.confirm')}
cancelLabel="Cancel" cancelLabel={t('c2.config.deleteConfirm.cancel')}
destructive destructive
onConfirm={onDelete} onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}

View File

@@ -1,5 +1,6 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { ChevronRight, ChevronDown } from 'lucide-react'; import { ChevronRight, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useC2Tasks } from '@/hooks/useC2'; import { useC2Tasks } from '@/hooks/useC2';
import { C2TaskStatusBadge } from './C2TaskStatusBadge'; import { C2TaskStatusBadge } from './C2TaskStatusBadge';
@@ -8,6 +9,7 @@ interface C2TasksPanelProps {
} }
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element { export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
const { t } = useTranslation();
const query = useC2Tasks(simulationId, { enabled: true }); const query = useC2Tasks(simulationId, { enabled: true });
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
@@ -33,13 +35,13 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
className="card-product flex flex-col gap-md" className="card-product flex flex-col gap-md"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3> <h3 className="text-[16px] font-medium text-ink">{t('c2.tasks.title')}</h3>
{isRefreshing && ( {isRefreshing && (
<span <span
data-testid="c2-task-refresh-indicator" data-testid="c2-task-refresh-indicator"
className="text-[12px] text-graphite" className="text-[12px] text-graphite"
> >
Refreshing {t('c2.tasks.refreshing')}
</span> </span>
)} )}
</div> </div>
@@ -47,7 +49,7 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className="border border-hairline rounded-none px-md py-md"> <div className="border border-hairline rounded-none px-md py-md">
<p className="text-[14px] text-graphite"> <p className="text-[14px] text-graphite">
No C2 tasks yet. Use Execute via C2 to launch commands. {t('c2.tasks.empty')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -56,11 +58,11 @@ export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
<thead> <thead>
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" /> <th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" />
<th className="px-md py-sm text-left font-medium text-ink">Task</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.task')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Source</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.source')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.completedAt')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2'; import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker'; import { C2CallbackPicker } from './C2CallbackPicker';
@@ -17,6 +18,7 @@ export function ExecuteViaC2Modal({
initialCommands, initialCommands,
onClose, onClose,
}: ExecuteViaC2ModalProps): JSX.Element { }: ExecuteViaC2ModalProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
@@ -43,10 +45,10 @@ export function ExecuteViaC2Modal({
callback_display_id: selectedId, callback_display_id: selectedId,
commands: commandLines, commands: commandLines,
}); });
push(`${result.tasks.length} task(s) submitted`, 'success'); push(t('c2.modal.execute.toast.launched', { count: result.tasks.length }), 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not execute via C2')); setSubmitError(extractApiError(err, t('c2.modal.execute.error.launch')));
} }
}; };
@@ -62,12 +64,12 @@ export function ExecuteViaC2Modal({
<div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto"> <div className="relative card-product w-full max-w-3xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-modal-title" className="text-[20px] font-medium text-ink"> <h2 id="c2-modal-title" className="text-[20px] font-medium text-ink">
Execute via C2 {t('c2.modal.execute.title')}
</h2> </h2>
{/* Callback picker */} {/* Callback picker */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span> <span className="text-[14px] font-medium text-ink">{t('c2.modal.execute.callback')}</span>
<C2CallbackPicker <C2CallbackPicker
callbacks={callbacks} callbacks={callbacks}
isLoading={callbacksQuery.isLoading} isLoading={callbacksQuery.isLoading}
@@ -82,7 +84,7 @@ export function ExecuteViaC2Modal({
{/* Commands */} {/* Commands */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<label htmlFor="c2-commands" className="text-[14px] font-medium text-ink"> <label htmlFor="c2-commands" className="text-[14px] font-medium text-ink">
Commands {t('simulation.field.commands')}
</label> </label>
<textarea <textarea
id="c2-commands" id="c2-commands"
@@ -90,10 +92,10 @@ export function ExecuteViaC2Modal({
value={commands} value={commands}
onChange={(e) => setCommands(e.target.value)} onChange={(e) => setCommands(e.target.value)}
className="text-input min-h-[112px] py-sm font-mono text-[14px]" className="text-input min-h-[112px] py-sm font-mono text-[14px]"
placeholder="One command per line" placeholder={t('simulation.field.commands')}
/> />
<span className="text-[12px] text-graphite"> <span className="text-[12px] text-graphite">
{commandLines.length} command{commandLines.length !== 1 ? 's' : ''} one task per line {t('c2.modal.execute.commandCount', { count: commandLines.length })}
</span> </span>
</div> </div>
@@ -112,7 +114,7 @@ export function ExecuteViaC2Modal({
onClick={onLaunch} onClick={onLaunch}
disabled={!canLaunch || executeMutation.isPending} disabled={!canLaunch || executeMutation.isPending}
> >
{executeMutation.isPending ? 'Launching' : 'Launch'} {executeMutation.isPending ? t('c2.modal.execute.btn.launching') : t('c2.modal.execute.btn.launch')}
</button> </button>
<button <button
type="button" type="button"
@@ -120,7 +122,7 @@ export function ExecuteViaC2Modal({
onClick={onClose} onClick={onClose}
disabled={executeMutation.isPending} disabled={executeMutation.isPending}
> >
Cancel {t('c2.modal.execute.btn.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2'; import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
import { C2CallbackPicker } from './C2CallbackPicker'; import { C2CallbackPicker } from './C2CallbackPicker';
@@ -18,6 +19,7 @@ export function ImportC2HistoryModal({
engagementId, engagementId,
onClose, onClose,
}: ImportC2HistoryModalProps): JSX.Element { }: ImportC2HistoryModalProps): JSX.Element {
const { t } = useTranslation();
const { push } = useToast(); const { push } = useToast();
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true }); const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
@@ -70,12 +72,12 @@ export function ImportC2HistoryModal({
}); });
const msg = const msg =
result.skipped > 0 result.skipped > 0
? `Imported ${result.imported} task(s), ${result.skipped} already attached` ? t('c2.modal.import.toast.partial', { imported: result.imported, skipped: result.skipped })
: `Imported ${result.imported} task(s)`; : t('c2.modal.import.toast.imported', { count: result.imported });
push(msg, 'success'); push(msg, 'success');
onClose(); onClose();
} catch (err) { } catch (err) {
setSubmitError(extractApiError(err, 'Could not import tasks')); setSubmitError(extractApiError(err, t('c2.modal.import.error.import')));
} }
}; };
@@ -91,12 +93,12 @@ export function ImportC2HistoryModal({
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto"> <div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink"> <h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
Import C2 history {t('c2.modal.import.title')}
</h2> </h2>
{/* Step 1: callback picker */} {/* Step 1: callback picker */}
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">Select callback</span> <span className="text-[14px] font-medium text-ink">{t('c2.modal.picker.title')}</span>
<C2CallbackPicker <C2CallbackPicker
callbacks={callbacks} callbacks={callbacks}
isLoading={callbacksQuery.isLoading} isLoading={callbacksQuery.isLoading}
@@ -112,24 +114,24 @@ export function ImportC2HistoryModal({
{selectedCallbackId !== null && ( {selectedCallbackId !== null && (
<div className="flex flex-col gap-xs"> <div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink"> <span className="text-[14px] font-medium text-ink">
Task history{' '} {t('c2.tasks.title')}{' '}
{total > 0 && ( {total > 0 && (
<span className="text-graphite font-normal">({total} total)</span> <span className="text-graphite font-normal">({total} {t('c2.modal.import.total').toLowerCase()})</span>
)} )}
</span> </span>
{historyQuery.isLoading && ( {historyQuery.isLoading && (
<p className="text-[14px] text-graphite">Loading history</p> <p className="text-[14px] text-graphite">{t('state.loading')}</p>
)} )}
{historyQuery.isError && ( {historyQuery.isError && (
<p className="text-[14px] text-bloom-deep"> <p className="text-[14px] text-bloom-deep">
{extractApiError(historyQuery.error, 'Could not load history')} {extractApiError(historyQuery.error, t('c2.modal.import.error.import'))}
</p> </p>
)} )}
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && ( {!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
<p className="text-[14px] text-graphite">No task history for this callback.</p> <p className="text-[14px] text-graphite">{t('c2.modal.import.empty')}</p>
)} )}
{historyTasks.length > 0 && ( {historyTasks.length > 0 && (
@@ -140,10 +142,10 @@ export function ImportC2HistoryModal({
<tr className="bg-cloud border-b border-hairline"> <tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm w-8" aria-label="Select" /> <th className="px-md py-sm w-8" aria-label="Select" />
<th className="px-md py-sm text-left font-medium text-ink">#</th> <th className="px-md py-sm text-left font-medium text-ink">#</th>
<th className="px-md py-sm text-left font-medium text-ink">Command</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.command')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Status</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.tasks.col.status')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Completed</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.completed')}</th>
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th> <th className="px-md py-sm text-left font-medium text-ink">{t('c2.modal.import.col.timestamp')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -175,7 +177,7 @@ export function ImportC2HistoryModal({
<C2TaskStatusBadge status={task.status} /> <C2TaskStatusBadge status={task.status} />
</td> </td>
<td className="px-md py-sm text-[14px]"> <td className="px-md py-sm text-[14px]">
{task.completed ? 'Yes' : 'No'} {task.completed ? t('common.yes') : t('common.no')}
</td> </td>
<td className="px-md py-sm font-mono text-graphite"> <td className="px-md py-sm font-mono text-graphite">
{task.created_at} {task.created_at}
@@ -195,10 +197,10 @@ export function ImportC2HistoryModal({
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Prev {t('c2.modal.import.prev')}
</button> </button>
<span> <span>
Page {page} of {totalPages} {t('c2.modal.import.page')} {page} {t('c2.modal.import.of')} {totalPages}
</span> </span>
<button <button
type="button" type="button"
@@ -207,11 +209,11 @@ export function ImportC2HistoryModal({
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
> >
Next {t('c2.modal.import.next')}
</button> </button>
{checkedIds.size > 0 && ( {checkedIds.size > 0 && (
<span className="ml-auto text-ink"> <span className="ml-auto text-ink">
{checkedIds.size} selected {checkedIds.size} {t('c2.modal.import.selected')}
</span> </span>
)} )}
</div> </div>
@@ -235,7 +237,7 @@ export function ImportC2HistoryModal({
onClick={onImport} onClick={onImport}
disabled={!canImport || importMutation.isPending} disabled={!canImport || importMutation.isPending}
> >
{importMutation.isPending ? 'Importing' : 'Import selected'} {importMutation.isPending ? t('c2.modal.import.btn.importing') : t('c2.modal.import.btn.import')}
</button> </button>
<button <button
type="button" type="button"
@@ -243,7 +245,7 @@ export function ImportC2HistoryModal({
onClick={onClose} onClick={onClose}
disabled={importMutation.isPending} disabled={importMutation.isPending}
> >
Cancel {t('c2.modal.import.btn.cancel')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,9 @@
"create": "Créer", "create": "Créer",
"creating": "Création…", "creating": "Création…",
"close": "Fermer", "close": "Fermer",
"confirm": "Confirmer" "confirm": "Confirmer",
"yes": "Oui",
"no": "Non"
}, },
"nav": { "nav": {
"engagements": "Engagements", "engagements": "Engagements",
@@ -389,7 +391,7 @@
"commandsRequired": "Aucune commande à exécuter" "commandsRequired": "Aucune commande à exécuter"
}, },
"toast": { "toast": {
"launched": "Tâche C2 lancée" "launched": "{{count}} tâche(s) C2 lancée(s)"
}, },
"error": { "error": {
"launch": "Impossible de lancer la tâche C2" "launch": "Impossible de lancer la tâche C2"
@@ -409,9 +411,13 @@
}, },
"selected": "sélectionné(s)", "selected": "sélectionné(s)",
"empty": "Aucun historique C2 disponible.", "empty": "Aucun historique C2 disponible.",
"col": {
"timestamp": "Horodatage",
"completed": "Terminé"
},
"toast": { "toast": {
"imported": "Historique C2 importé", "imported": "{{count}} tâche(s) importée(s)",
"partial": "Import partiel" "partial": "{{imported}} importée(s), {{skipped}} déjà attachée(s)"
}, },
"error": { "error": {
"import": "Impossible d'importer l'historique C2" "import": "Impossible d'importer l'historique C2"
@@ -421,7 +427,19 @@
"title": "Sélectionner un agent", "title": "Sélectionner un agent",
"empty": "Aucun agent disponible.", "empty": "Aucun agent disponible.",
"hostnameColon": "Hôte :", "hostnameColon": "Hôte :",
"lastCheckinColon": "Dernière connexion :" "lastCheckinColon": "Dernière connexion :",
"col": {
"displayId": "ID",
"active": "Actif",
"host": "Hôte",
"user": "Utilisateur",
"domain": "Domaine",
"lastCheckin": "Dernière connexion"
},
"status": {
"active": "Actif",
"inactive": "Inactif"
}
} }
} }
}, },

View File

@@ -57,7 +57,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
it('shows Replace token affordance when has_token=true', async () => { it('shows Replace token affordance when has_token=true', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument(); expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
}); });
// Token input shows placeholder bullets (readOnly) // Token input shows placeholder bullets (readOnly)
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement; const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
@@ -75,9 +75,9 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
it('clicking Replace token makes input editable', async () => { it('clicking Replace token makes input editable', async () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Replace token')).toBeInTheDocument(); expect(screen.getByText('Remplacer le token')).toBeInTheDocument();
}); });
fireEvent.click(screen.getByText('Replace token')); fireEvent.click(screen.getByText('Remplacer le token'));
await waitFor(() => { await waitFor(() => {
const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement; const tokenInput = screen.getByTestId('c2-token-input') as HTMLInputElement;
expect(tokenInput.readOnly).toBeFalsy(); expect(tokenInput.readOnly).toBeFalsy();
@@ -99,7 +99,7 @@ describe('C2ConfigCard — with config (has_token=true)', () => {
}); });
fireEvent.click(screen.getByTestId('c2-test-btn')); fireEvent.click(screen.getByTestId('c2-test-btn'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument(); expect(screen.getByText('Connecté')).toBeInTheDocument();
}); });
}); });
@@ -124,7 +124,7 @@ describe('C2ConfigCard — 503 disabled state', () => {
renderWithProviders(<C2ConfigCard engagementId={1} />); renderWithProviders(<C2ConfigCard engagementId={1} />);
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText(/C2 features are disabled/i), screen.getByText(/fonctionnalités C2 sont désactivées/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
expect(screen.getByTestId('c2-save-btn')).toBeDisabled(); expect(screen.getByTestId('c2-save-btn')).toBeDisabled();

View File

@@ -66,7 +66,7 @@ describe('C2TasksPanel — empty state', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument(); expect(screen.getByTestId('c2-tasks-panel')).toBeInTheDocument();
}); });
expect(screen.getByText(/No C2 tasks yet/i)).toBeInTheDocument(); expect(screen.getByText(/Aucune tâche C2/i)).toBeInTheDocument();
expect(screen.queryByTestId('c2-task-row')).toBeNull(); expect(screen.queryByTestId('c2-task-row')).toBeNull();
}); });
}); });

View File

@@ -66,7 +66,7 @@ describe('ExecuteViaC2Modal', () => {
it('renders modal with title and callback table', async () => { it('renders modal with title and callback table', async () => {
renderModal(); renderModal();
expect(screen.getByTestId('c2-modal')).toBeInTheDocument(); expect(screen.getByTestId('c2-modal')).toBeInTheDocument();
expect(screen.getByText('Execute via C2')).toBeInTheDocument(); expect(screen.getByText('Exécuter via C2')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
}); });
@@ -148,7 +148,7 @@ describe('ExecuteViaC2Modal', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-callback-row')).toHaveLength(2);
}); });
fireEvent.click(screen.getByRole('button', { name: /cancel/i })); fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });

View File

@@ -88,7 +88,7 @@ describe('ImportC2HistoryModal — step 1: callback picker', () => {
it('renders modal with title and callback rows', async () => { it('renders modal with title and callback rows', async () => {
renderModal(); renderModal();
expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument(); expect(screen.getByTestId('c2-import-modal')).toBeInTheDocument();
expect(screen.getByText('Import C2 history')).toBeInTheDocument(); expect(screen.getByText("Importer l'historique C2")).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
}); });
@@ -322,7 +322,7 @@ describe('ImportC2HistoryModal — toast wording', () => {
fireEvent.click(screen.getByTestId('c2-import-submit-btn')); fireEvent.click(screen.getByTestId('c2-import-submit-btn'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Imported 1 task(s)')).toBeInTheDocument(); expect(screen.getByText('1 tâche(s) importée(s)')).toBeInTheDocument();
}); });
}); });
@@ -342,7 +342,7 @@ describe('ImportC2HistoryModal — toast wording', () => {
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText('Imported 0 task(s), 1 already attached'), screen.getByText('0 importée(s), 1 déjà attachée(s)'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
@@ -374,7 +374,7 @@ describe('ImportC2HistoryModal — Cancel button', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2); expect(screen.getAllByTestId('c2-import-callback-row')).toHaveLength(2);
}); });
fireEvent.click(screen.getByRole('button', { name: /cancel/i })); fireEvent.click(screen.getByRole('button', { name: /Annuler/i }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
}); });