refactor(decnet_web/AttackerDetail): extract MailLogPanel section

Lift STORED MAIL into its own section and pull the mail drawer
selection state along with it. Section signals admin-gating
through the section's own props (mailForbidden), since the data
hook already converts a 403 into that boolean.

- New AttackerDetail/sections/MailLogPanel.tsx
- MailLogPanel.test.tsx covers row rendering, mailForbidden empty
  state, no-mail empty state, from_hdr/from_addr/mail_from
  fallback, and drawer open/close. MailDrawer vi.mock'd same as
  ArtifactDrawer.
- AttackerDetail.tsx loses the mail JSX block, mailItem state,
  and now-unused Mail/MailDrawer imports.
This commit is contained in:
2026-05-09 04:48:44 -04:00
parent 14713eb294
commit d5efebd73d
3 changed files with 249 additions and 95 deletions

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, FileText, Mail, AtSign } from '../icons';
import { Activity, AlertTriangle, ArrowLeft, Cpu, Crosshair, Eye, Fingerprint, Globe, Keyboard, Shield, Clock, Sparkles, Wifi, Lock, FileKey, Radio, Timer, FileText, AtSign } from '../icons';
import api from '../utils/api';
import MailDrawer from './MailDrawer';
import SessionDrawer from './SessionDrawer';
import EmptyState from './EmptyState/EmptyState';
import TTPsObservedSection from './TTPsObservedSection';
@@ -13,6 +12,7 @@ import { TimelineSection } from './AttackerDetail/sections/TimelineSection';
import { ServicesTargeted } from './AttackerDetail/sections/ServicesTargeted';
import { CommandsViewer } from './AttackerDetail/sections/CommandsViewer';
import { ArtifactsPanel } from './AttackerDetail/sections/ArtifactsPanel';
import { MailLogPanel } from './AttackerDetail/sections/MailLogPanel';
import { Tag, Section } from './AttackerDetail/ui';
import type {
AttackerBehavior,
@@ -1283,10 +1283,9 @@ const AttackerDetail: React.FC = () => {
});
// Drawer selection (ephemeral UI; data feeds come from the hook).
// Drawer selection (mail/session). The artifact drawer state moved
// into ArtifactsPanel; mail and session follow in the next commits.
// Drawer selection (session). Artifact + mail drawer state are
// owned by their respective sections.
const [session, setSession] = useState<{ decky: string; sid: string; fields: Record<string, any> } | null>(null);
const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
@@ -1552,98 +1551,12 @@ const AttackerDetail: React.FC = () => {
)}
</Section>
{/* Stored Mail (admin only — bodies are attacker-controlled) */}
<Section
title={<>STORED MAIL ({mail.length})</>}
<MailLogPanel
mail={mail}
mailForbidden={mailForbidden}
open={openSections.mail}
onToggle={() => toggle('mail')}
>
{mailForbidden ? (
<EmptyState
icon={Mail}
title="ADMIN ROLE REQUIRED"
size="compact"
/>
) : mail.length > 0 ? (
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SUBJECT</th>
<th>FROM</th>
<th>DATE (attacker)</th>
<th>SIZE</th>
<th></th>
</tr>
</thead>
<tbody>
{mail.map((row) => {
let fields: Record<string, any> = {};
try { fields = JSON.parse(row.fields || '{}'); } catch {}
const storedAs = fields.stored_as ? String(fields.stored_as) : null;
return (
<tr key={row.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{new Date(row.timestamp).toLocaleString()}
</td>
<td className="violet-accent">{row.decky}</td>
<td className="matrix-text" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{fields.subject || '—'}
</td>
<td className="matrix-text" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{fields.from_hdr || fields.from_addr || fields.mail_from || '—'}
</td>
<td className="matrix-text" style={{ fontFamily: 'monospace', whiteSpace: 'nowrap', fontSize: '0.75rem' }}>
{fields.date_hdr || '—'}
</td>
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
{fields.size ? `${fields.size} B` : '—'}
</td>
<td>
{storedAs && (
<button
onClick={() => setMailItem({ decky: row.decky, storedAs, fields })}
title="Inspect stored message"
style={{
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '0.7rem',
backgroundColor: 'var(--warn-tint-10)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid var(--warn)',
color: 'var(--warn)',
cursor: 'pointer',
}}
>
<Mail size={11} /> OPEN
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<EmptyState
icon={Mail}
title="NO MAIL STORED"
size="compact"
/>
)}
</Section>
{mailItem && (
<MailDrawer
decky={mailItem.decky}
storedAs={mailItem.storedAs}
fields={mailItem.fields}
onClose={() => setMailItem(null)}
/>
)}
/>
{/* Recorded PTY Sessions (SSH / Telnet) */}
<Section

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MailLogPanel } from './MailLogPanel';
import type { MailLog } from '../types';
vi.mock('../../MailDrawer', () => ({
default: ({ storedAs, onClose }: { storedAs: string; onClose: () => void }) => (
<div data-testid="mail-drawer">
drawer for {storedAs}
<button onClick={onClose}>close</button>
</div>
),
}));
const fields = (extra: Record<string, unknown> = {}) =>
JSON.stringify({
stored_as: '/var/mail/attacker.eml',
subject: 'Hello victim',
from_hdr: 'attacker@example.invalid',
date_hdr: 'Sat, 09 May 2026 11:00:00 +0000',
size: 2048,
...extra,
});
const row = (overrides: Partial<MailLog> = {}): MailLog => ({
id: 1,
timestamp: '2026-05-09T11:00:00Z',
decky: 'decoy-01',
service: 'smtp',
fields: fields(),
...overrides,
});
describe('MailLogPanel', () => {
it('renders rows with subject + from header parsed from SD fields', () => {
render(
<MailLogPanel
mail={[row()]}
mailForbidden={false}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText('Hello victim')).toBeInTheDocument();
expect(screen.getByText('attacker@example.invalid')).toBeInTheDocument();
});
it('renders the admin-required empty state when mailForbidden is true', () => {
render(
<MailLogPanel mail={[]} mailForbidden={true} open={true} onToggle={() => {}} />,
);
expect(screen.getByText('ADMIN ROLE REQUIRED')).toBeInTheDocument();
});
it('renders the no-mail empty state when not forbidden and list is empty', () => {
render(
<MailLogPanel mail={[]} mailForbidden={false} open={true} onToggle={() => {}} />,
);
expect(screen.getByText('NO MAIL STORED')).toBeInTheDocument();
});
it('falls back through from_hdr -> from_addr -> mail_from', () => {
render(
<MailLogPanel
mail={[
row({
fields: JSON.stringify({
stored_as: '/var/mail/x.eml',
mail_from: 'envelope@from.invalid',
}),
}),
]}
mailForbidden={false}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.getByText('envelope@from.invalid')).toBeInTheDocument();
});
it('opens and closes the MailDrawer on row OPEN button', async () => {
const user = userEvent.setup();
render(
<MailLogPanel
mail={[row()]}
mailForbidden={false}
open={true}
onToggle={() => {}}
/>,
);
expect(screen.queryByTestId('mail-drawer')).not.toBeInTheDocument();
await user.click(screen.getByText(/^OPEN$/));
expect(screen.getByTestId('mail-drawer')).toBeInTheDocument();
await user.click(screen.getByText('close'));
expect(screen.queryByTestId('mail-drawer')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { Mail } from '../../../icons';
import EmptyState from '../../EmptyState/EmptyState';
import MailDrawer from '../../MailDrawer';
import { Section } from '../ui';
import type { MailLog } from '../types';
interface Props {
mail: MailLog[];
mailForbidden: boolean;
open: boolean;
onToggle: () => void;
}
interface DrawerSelection {
decky: string;
storedAs: string;
fields: Record<string, unknown>;
}
/** STORED MAIL collapsible — admin-gated (the bodies are
* attacker-controlled and never shown to lower roles). When the
* data hook reports `mailForbidden` from a 403 response the section
* renders an explicit "ADMIN ROLE REQUIRED" empty state instead of
* the generic "no rows" copy. */
export const MailLogPanel: React.FC<Props> = ({
mail,
mailForbidden,
open,
onToggle,
}) => {
const [selected, setSelected] = useState<DrawerSelection | null>(null);
return (
<>
<Section
title={<>STORED MAIL ({mail.length})</>}
open={open}
onToggle={onToggle}
>
{mailForbidden ? (
<EmptyState icon={Mail} title="ADMIN ROLE REQUIRED" size="compact" />
) : mail.length > 0 ? (
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SUBJECT</th>
<th>FROM</th>
<th>DATE (attacker)</th>
<th>SIZE</th>
<th></th>
</tr>
</thead>
<tbody>
{mail.map((row) => {
let fields: Record<string, unknown> = {};
try {
fields = JSON.parse(row.fields || '{}');
} catch {
// malformed SD params — preview unavailable
}
const storedAs = fields.stored_as ? String(fields.stored_as) : null;
return (
<tr key={row.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{new Date(row.timestamp).toLocaleString()}
</td>
<td className="violet-accent">{row.decky}</td>
<td
className="matrix-text"
style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}
>
{(fields.subject as string | undefined) || '—'}
</td>
<td
className="matrix-text"
style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}
>
{(fields.from_hdr as string | undefined) ||
(fields.from_addr as string | undefined) ||
(fields.mail_from as string | undefined) ||
'—'}
</td>
<td
className="matrix-text"
style={{
fontFamily: 'monospace',
whiteSpace: 'nowrap',
fontSize: '0.75rem',
}}
>
{(fields.date_hdr as string | undefined) || '—'}
</td>
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>
{fields.size ? `${fields.size} B` : '—'}
</td>
<td>
{storedAs && (
<button
onClick={() => setSelected({ decky: row.decky, storedAs, fields })}
title="Inspect stored message"
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '0.7rem',
backgroundColor: 'var(--warn-tint-10)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid var(--warn)',
color: 'var(--warn)',
cursor: 'pointer',
}}
>
<Mail size={11} /> OPEN
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<EmptyState icon={Mail} title="NO MAIL STORED" size="compact" />
)}
</Section>
{selected && (
<MailDrawer
decky={selected.decky}
storedAs={selected.storedAs}
fields={selected.fields}
onClose={() => setSelected(null)}
/>
)}
</>
);
};