docs(22): UI design contract for approval UI (LiveLink)
Composes existing Pulse design tokens into a phase-specific contract: page layout for the ticket-scoped review page and new campaigns list page, evidence/timeline/classification display specs, and the approve/remediate/mark-false-positive action-area contract (D-03..D-06 disabled-state and confirmation copy). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
parent
73f2e25644
commit
fd2280ab2c
1 changed files with 383 additions and 0 deletions
|
|
@ -0,0 +1,383 @@
|
|||
---
|
||||
phase: 22
|
||||
slug: approval-ui-livelink-addressable-campaign-review-and-approve
|
||||
status: draft
|
||||
shadcn_initialized: true
|
||||
preset: "style: new-york, baseColor: neutral, iconLibrary: lucide, rsc: true (existing project-wide components.json — not phase-specific)"
|
||||
created: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 22 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for Approval UI (LiveLink): the ticket-scoped
|
||||
> campaign review page (`/phishing/tickets/{ticketId}`) and the new campaigns
|
||||
> list page (`/phishing`). Generated by gsd-ui-researcher, verified by
|
||||
> gsd-ui-checker.
|
||||
|
||||
All tokens below are the **existing Pulse project design system** (`DESIGN.md`),
|
||||
pre-populated here, not re-decided for this phase. This phase introduces zero
|
||||
new tokens — it composes existing primitives + one new shadcn primitive
|
||||
(`tooltip`, needed for D-05/D-06 disabled-state explanations, not currently in
|
||||
`components/ui/`).
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | shadcn (already initialized — `components.json` present) |
|
||||
| Preset | `style: new-york`, `baseColor: neutral`, `cssVariables: true`, no registries beyond official |
|
||||
| Component library | Radix primitives via shadcn |
|
||||
| Icon library | `lucide-react` |
|
||||
| Font | IBM Plex Sans (`--font-plex-sans`, weights 300–700); IBM Plex Mono for numerics/IDs/timestamps/hashes |
|
||||
| New primitive required | `tooltip` — run `npx shadcn add tooltip` before/during Wave 1. Nothing else needs adding; `checkbox`, `tabs`, `accordion`, `alert`, `alert-dialog`, `card`, `select`, `table` all already exist. |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (existing Pulse scale, multiples of 4 — see `DESIGN.md` §3):
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| xs | 4px | Icon gaps, badge inline padding |
|
||||
| sm | 8px | Inline row gaps (`gap-2`), compact spacing |
|
||||
| md | 16px | Form field gaps (`gap-4`), default element spacing |
|
||||
| lg | 24px | Card body padding, section gap (`space-y-6` between page sections) |
|
||||
| xl | 32px | Layout gaps (rare on this page) |
|
||||
| 2xl | 48px | Not used on this phase's pages |
|
||||
| 3xl | 64px | Not used on this phase's pages |
|
||||
|
||||
**Container/page rule (inherited, D-01):** `container mx-auto px-6 py-6`, sections separated by `space-y-6`, grid layouts use `gap-6`.
|
||||
|
||||
**Exceptions:**
|
||||
- Copy-to-clipboard icon buttons (URL/hash rows) use the shadcn `Button size="icon"` default (32px / `h-8 w-8`), not a 44px touch target — matches the rest of Pulse's desktop-density console; these are secondary affordances next to already-selectable monospace text, not primary touch targets.
|
||||
- Timeline rail dots: 8px (`h-2 w-2`), matching `components/ui/status-light.tsx`'s smallest size token — not a new size.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
| Role | Size | Weight | Line Height |
|
||||
|------|------|--------|-------------|
|
||||
| Body | 14px (`text-sm`) | 400 (regular) | 1.5 |
|
||||
| Label | 12px (`text-xs font-medium`) | 500 (medium) | 1.4 |
|
||||
| Heading (card title) | 18px (`text-lg font-semibold`) | 600 (semibold) | 1.2 |
|
||||
| Display (page title) | 24px (`text-2xl font-bold tracking-tight`) | 700 (bold) | 1.2 |
|
||||
|
||||
This phase introduces two new weights of its own: **400 (regular)** for body/evidence text and **600 (semibold)** for card titles/labels — matching `DESIGN.md`'s existing `text-lg font-semibold` card-title convention. The **700 (bold)** Display row is the pre-existing, unchanged `PageHeader` H1 (`components/navigation/page-header.tsx`), reused as-is on both new pages — not a new weight this phase introduces, called out here only because the template requires all four roles documented.
|
||||
|
||||
Monospace (IBM Plex Mono, `font-mono`) is mandatory for: Message-ID, Return-Path, sender/recipient email addresses in header dumps, extracted URLs (D-09), attachment hashes (D-10), and all timestamps — per `DESIGN.md` §2's "mono for numerics/IDs/timestamps" rule.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | `--background` / `--card` (white / near-black) | Page surface, card surfaces |
|
||||
| Secondary (30%) | `--muted` / `--secondary` (near-white gray / dark gray) | Card headers, table header rows, the body-preview `<pre>` block background, disabled-row backgrounds |
|
||||
| Accent (10%) | `--primary` (Wulf blue `oklch(0.55 0.16 220)`) | Reserved for the specific elements listed below only |
|
||||
| Destructive | `--destructive` | Reserved for the specific elements listed below only |
|
||||
|
||||
**Accent (`--primary`) reserved for — nothing else:**
|
||||
- The "Approve selected" primary submit button (only primary-filled button on the Action Area card)
|
||||
- The "Analyze this ticket" CTA button in the D-07 not-yet-triaged empty state
|
||||
- Focus rings on all inputs/buttons/checkboxes (existing global convention, unchanged)
|
||||
- The active-tab underline inside the Evidence card's tab strip (mirrors the existing top-nav active-state convention, `DESIGN.md` §4)
|
||||
|
||||
**Destructive (`--destructive`) reserved for — nothing else:**
|
||||
- The "Mark as false positive" button and its confirmation `AlertDialog` (irreversible per Phase 20's no-un-mark-path decision)
|
||||
- The THREAT verdict `StatusBadge`
|
||||
- The "rejected" per-recipient blast-radius status badge
|
||||
|
||||
**Everything else uses semantic hue badges per `DESIGN.md`'s `bg-{hue}-500/15 text-{hue}-600` recipe — never `--primary` or `--destructive`:**
|
||||
|
||||
| Element | Tone | Recipe |
|
||||
|---|---|---|
|
||||
| SPAM verdict badge | slate | `bg-slate-500/15 text-slate-600` |
|
||||
| UNWANTED verdict badge | amber | `bg-amber-500/15 text-amber-600` |
|
||||
| THREAT verdict badge | red (destructive) | `bg-destructive/15 text-destructive` |
|
||||
| SPF/DKIM/DMARC "pass" | green | `bg-green-500/15 text-green-600` |
|
||||
| SPF/DKIM/DMARC "fail" | red | `bg-red-500/15 text-red-600` |
|
||||
| SPF/DKIM/DMARC "none"/neutral | slate | `bg-slate-500/15 text-slate-600` |
|
||||
| Blast-radius "delivered" | green | `bg-green-500/15 text-green-600` |
|
||||
| Blast-radius "held" | amber | `bg-amber-500/15 text-amber-600` |
|
||||
| Blast-radius "rejected" | destructive (see above) | `bg-destructive/15 text-destructive` |
|
||||
| Blast-radius "unknown" | muted | `bg-muted text-muted-foreground` |
|
||||
| `remediation_actions.status = proposed` | slate | `bg-slate-500/15 text-slate-600` |
|
||||
| `remediation_actions.status = approved` | blue | `bg-blue-500/15 text-blue-600` |
|
||||
| `remediation_actions.status = completed` | green | `bg-green-500/15 text-green-600` |
|
||||
| `campaigns.status = false_positive` | slate | `bg-slate-500/15 text-slate-600` |
|
||||
| Secondary buttons (Remediate, Reclassify) | outline variant | shadcn `Button variant="outline"` — no fill color |
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Primary CTA (Action Area) | "Approve selected" — disabled until ≥1 checkbox is checked |
|
||||
| Secondary CTA (Action Area) | "Remediate approved actions" |
|
||||
| Destructive CTA (Action Area) | "Mark as false positive" |
|
||||
| Reclassify CTA (Classification card) | "Reclassify" |
|
||||
| D-07 empty state (no report yet) heading | "Not yet triaged" |
|
||||
| D-07 empty state body | "This ticket hasn't been scanned for phishing indicators yet. Run analysis now to extract evidence and see a classification." |
|
||||
| D-07 empty state CTA | "Analyze this ticket" → `POST /api/phishing/tickets/{ticket_id}/analyze`, then refetch |
|
||||
| D-08 standalone-report notice (report exists, no campaign yet) | Inline `Alert` (info tone): "Grouping in progress — this report hasn't been linked to a campaign yet. The evidence below is from this report only; classification and remediation will appear once grouping completes." |
|
||||
| Error state (load failure) | "Couldn't load this campaign. {error message} — try reloading the page." + "Retry" button |
|
||||
| Blast radius unavailable (not configured) | "Blast radius unavailable — Mimecast isn't configured for this environment." |
|
||||
| Blast radius unavailable (lookup failed) | "Blast radius lookup failed: {error}. Classification proceeded without it." |
|
||||
| URLs tab empty | "No URLs found in this message." |
|
||||
| Destructive confirmation — Mark as false positive | "Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it later." Confirm button label: "Mark as false positive" (destructive variant) |
|
||||
| Destructive confirmation — Remediate | "Remediate {N} approved action(s): {comma-separated humanized action types}. This executes the simulated remediation effect and cannot be undone." Confirm button label: "Remediate" (destructive variant, since it commits an irreversible state transition even though the underlying provider call is simulated in this milestone) |
|
||||
| Disabled-tooltip — no permission | "Requires {approve/remediate} permission" |
|
||||
| Disabled-tooltip — already resolved (remediated) | "Already remediated on {date} by {approver}" |
|
||||
| Disabled-tooltip — already resolved (false positive) | "Marked as false positive on {date}" |
|
||||
| Disabled-tooltip — nothing checked | "Select at least one action to approve" |
|
||||
| Disabled-tooltip — nothing approved | "No approved actions to remediate" |
|
||||
| Disabled-tooltip — false-positive blocked by remediation | "Cannot mark false positive — this campaign already has approved or completed remediation" |
|
||||
| Toast — approve success | "Approved {N} remediation action(s)" |
|
||||
| Toast — remediate success | "Remediation completed for {N} action(s)" |
|
||||
| Toast — mark-false-positive success | "Campaign marked as false positive" |
|
||||
| Toast — any action failure | "{Action} failed: {error message}" |
|
||||
| Campaigns list page title | "Phishing Campaigns" |
|
||||
| Campaigns list page description | "Automatically detected phishing and spam campaigns awaiting triage." |
|
||||
| Campaigns list empty state | Heading: "No campaigns yet" / Body: "Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| shadcn official | `tooltip` (new addition this phase), plus existing `checkbox`, `tabs`, `accordion`, `alert`, `alert-dialog`, `card`, `select`, `table`, `button`, `badge`, `separator`, `collapsible` | not required (official registry) |
|
||||
| Third-party | none | not applicable — no third-party registries declared, `components.json` has `"registries": {}` |
|
||||
|
||||
---
|
||||
|
||||
## Page Composition
|
||||
|
||||
### Route map
|
||||
|
||||
| Route | Purpose | Requirement |
|
||||
|---|---|---|
|
||||
| `/phishing` | NEW — minimal campaigns list, entry point for the nav (D-00) | additive, not REVIEW-01..06 |
|
||||
| `/phishing/tickets/[ticketId]` | Ticket-ID-addressable review page — the LiveLink target | REVIEW-01..06 |
|
||||
|
||||
Both use the full chrome shell (D-01): `<AppNavigation />` + `<PageHeader />` + `<main className="container mx-auto px-6 py-6">`.
|
||||
|
||||
### Nav placement (D-02)
|
||||
|
||||
Add a flat top-level item to `components/navigation/app-navigation.tsx`'s `navigationItems`, immediately after "PAX8" and before "Backup Status" — matching the existing precedent of a single-entity top-level item with no submenu (PAX8 is the most recent example of exactly this shape). Do **not** nest it under an existing dropdown (Analyzer/Admin) — phishing triage is its own operational surface, not a sub-tool of either.
|
||||
|
||||
```ts
|
||||
{
|
||||
title: 'Phishing',
|
||||
href: '/phishing',
|
||||
icon: ShieldAlert, // lucide-react — distinct from Analyzer's Sparkles and Admin's Activity
|
||||
description: 'Phishing/spam campaign triage, evidence, and remediation approval',
|
||||
}
|
||||
```
|
||||
|
||||
Visibility: no role gate — every role has `phishing: ["read"]` at minimum (`lib/permissions.ts`), so the item renders for `user`, `admin`, and `super-admin` alike (`DESIGN.md`'s "hide items the user can't access" principle — nobody is fully excluded from `read`). Approve/remediate/mark-false-positive gating happens inside the page (D-06), not at the nav level.
|
||||
|
||||
### Ticket-scoped review page — layout order
|
||||
|
||||
Single-column stack (`space-y-6`) at `<md`; a 2-column grid for the bottom two read-only panels at `lg+`. Order (top to bottom) is fixed and intentional: classification (the "so what") and the action area (the actual job) come before the supporting evidence/timeline detail — an operator arriving via LiveLink should see the verdict and be able to act without scrolling.
|
||||
|
||||
```
|
||||
<PageHeader
|
||||
title="Ticket #{ticketNumber}"
|
||||
description="{campaign.status === 'open' ? 'Awaiting triage' : humanized campaign.status}"
|
||||
breadcrumbs={['Phishing', 'Ticket #{ticketNumber}']}
|
||||
/>
|
||||
<main>
|
||||
[state-dependent body — see "Empty & Error States" below]
|
||||
|
||||
On full-campaign success:
|
||||
1. <ClassificationCard /> — full width
|
||||
2. <ActionAreaCard /> — full width
|
||||
3. <div className="grid gap-6 lg:grid-cols-2">
|
||||
<EvidenceCard /> — left column, lg+; stacks above Timeline on <lg
|
||||
<TimelineCard /> — right column, lg+
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
Each section is a shadcn `<Card>` with a `<CardHeader><CardTitle>` (18px/600 per Typography) carrying a leading `lucide-react` icon (`h-4 w-4 mr-2`, inline per `DESIGN.md` §6).
|
||||
|
||||
### Campaigns list page — layout
|
||||
|
||||
```
|
||||
<PageHeader title="Phishing Campaigns" description="Automatically detected phishing and spam campaigns awaiting triage." />
|
||||
<main>
|
||||
<DataTable columns={...} data={campaigns} /> — components/admin/DataTable.tsx
|
||||
</main>
|
||||
```
|
||||
|
||||
Use `DataTable` (not the raw shadcn `Table`) since campaign volume is expected to grow past 25 rows over time (`DESIGN.md` §7's threshold) and operators will want to sort/search. Columns:
|
||||
|
||||
| Column | Source | Render |
|
||||
|---|---|---|
|
||||
| Campaign | `campaignKey` (fallback: "Campaign {id.slice(0,8)}") | `font-mono text-sm` |
|
||||
| Status | `status` | `StatusBadge` per the color table above |
|
||||
| Reports | `reportCount` | `.num` utility, right-aligned |
|
||||
| First seen | `firstSeenAt` | relative + absolute on hover, `font-mono text-xs` |
|
||||
| Last seen | `lastSeenAt` | relative + absolute on hover, `font-mono text-xs` |
|
||||
|
||||
Row click navigates to `/phishing/tickets/{firstReportTicketId}`. **Data requirement this UI-SPEC prescribes:** `GET /api/phishing/campaigns` must additionally return `firstReportTicketId` (Autotask ticket ID of the campaign's earliest linked report, via a cheap `LEFT JOIN LATERAL`/subquery against `reports` ordered by `created_at ASC LIMIT 1`) — additive field, does not change any existing consumer's shape. This keeps exactly one review-page implementation (ticket-scoped, per REVIEW-01) rather than building a second campaign-ID-scoped page just for list-row navigation. If planner finds a cleaner path (e.g. reusing `reportsRes` already fetched elsewhere), that's fine — the contract is the *navigation target*, not the implementation.
|
||||
|
||||
If `campaigns` list is empty: render `<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />` in place of the table.
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
| Component | Status | Notes |
|
||||
|---|---|---|
|
||||
| `components/ui/tooltip.tsx` | **NEW** — `npx shadcn add tooltip` | Needed for D-05/D-06 disabled-button explanations. Does not exist in the codebase today (confirmed by grep). |
|
||||
| `components/phishing/classification-card.tsx` | NEW | REVIEW-04 |
|
||||
| `components/phishing/action-area-card.tsx` | NEW | REVIEW-05, D-03, D-04, D-05, D-06 |
|
||||
| `components/phishing/evidence-card.tsx` | NEW | REVIEW-03 |
|
||||
| `components/phishing/timeline-card.tsx` | NEW | REVIEW-02 |
|
||||
| `components/phishing/url-list.tsx` | NEW | D-09 inert copy-only URL rows, reusable across evidence tab + any future surface |
|
||||
| `components/admin/DataTable.tsx` | reused, unchanged | campaigns list |
|
||||
| `components/ui/empty-state.tsx` | reused, unchanged | D-07, campaigns-list-empty |
|
||||
| `components/ui/status-badge.tsx` | reused, unchanged | every badge in the color table above |
|
||||
| `components/navigation/page-header.tsx` | reused, unchanged | both pages |
|
||||
| `components/ui/alert.tsx` | reused | D-08 notice, blast-radius-unavailable notice |
|
||||
| `components/ui/alert-dialog.tsx` | reused | Remediate + Mark-false-positive confirmations |
|
||||
| `components/ui/tabs.tsx` | reused | Evidence card sections (Headers / URLs / Attachments / Body preview / Blast Radius) |
|
||||
| `components/ui/checkbox.tsx` | reused | Action Area recommended-actions list |
|
||||
| `components/ui/skeleton-helpers.tsx` | reused | loading state (`SkeletonHeader` + 3× `SkeletonCard`) |
|
||||
| `sonner` (`toast`) | reused | all action feedback, per `DESIGN.md` §8 |
|
||||
|
||||
---
|
||||
|
||||
## Evidence Display Spec (REVIEW-03)
|
||||
|
||||
`EvidenceCard` renders one message's parsed evidence. If the campaign has more than one linked report/message, an outer `<Select>` ("Report: Ticket #{ticketNumber} — {relative date}") chooses which message's evidence populates the tabs below; default selection = most recently linked report. Tabs (`components/ui/tabs.tsx`):
|
||||
|
||||
1. **Headers** — a 2-column definition-list grid (`grid grid-cols-2 gap-x-6 gap-y-2 text-sm`) of From / Display name / Sender domain / Reply-To / Return-Path / To / Cc / Subject / Date / Message-ID / Received chain (collapsed under an `Accordion` item since it can be long) / SPF / DKIM / DMARC. SPF/DKIM/DMARC render as `StatusBadge` (pass=green, fail=red, none=slate, per the color table). Message-ID, Return-Path, and all email addresses render `font-mono text-xs`.
|
||||
2. **URLs (D-09)** — `<UrlList>`: each extracted URL as `<code className="font-mono text-xs truncate">{url}</code>` with a trailing icon-only Copy button (`lucide-react` `Copy`, `size="icon"` `variant="ghost"`) that calls `navigator.clipboard.writeText(url)` + `toast.success('Copied')`. **Never** an `<a>`/`href`, never a click handler that navigates. Empty state: "No URLs found in this message." (size="sm" inline text, not the full `EmptyState` component — this is a tab body, not a page section).
|
||||
3. **Attachments (D-10)** — shadcn `Table`: filename, content-type (`StatusBadge variantClass="bg-slate-500/15 text-slate-600"`), size (human-readable, e.g. "482 KB"), hash (`font-mono text-xs`, truncated with a `title` attribute showing the full value, same Copy-button affordance as URLs — copying a hash carries no click risk, so this one is decorative convenience, not a safety requirement). No content is ever fetched or rendered — filenames/hashes only, per EVID-04.
|
||||
4. **Body preview** — `<pre className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">{bodyPreview}</pre>`. Plain text only — never `dangerouslySetInnerHTML`, never rendered as HTML even if the source email was HTML. This is the sanitized/truncated preview column (`messages.body_preview`), not the raw body.
|
||||
5. **Blast Radius** — reads `getBlastRadius()` output (planner's call whether fetched fresh per page load or read from the latest classification's persisted `reasons`, per CONTEXT.md discretion). If `status === 'unavailable'`: render the copy from the Copywriting Contract table above (reason-specific), no data table, no CTA — this is informational only. If `status === 'ok'`: a stat row of labeled numbers (Matched / Delivered / Held / Rejected / Clicked, each `.num` utility) above a per-recipient `Table` (recipient email `font-mono text-xs`, status `StatusBadge` per the color table).
|
||||
|
||||
---
|
||||
|
||||
## Classification Display Spec (REVIEW-04)
|
||||
|
||||
`ClassificationCard` — read-only display of the latest `classifications` row (`ORDER BY created_at DESC LIMIT 1`).
|
||||
|
||||
- Header row: verdict `StatusBadge` (per color table) + `{confidence}% confidence` (`.num` inline) + right-aligned "Reclassify" button (`variant="outline"`, `size="sm"`), gated: `hasPermission(session.user.role, 'phishing', 'analyze')` — hidden (not disabled) when absent, since re-triggering analysis is a distinct, lower-stakes action than approve/remediate and REVIEW-06's disabled-vs-hidden requirement only names approve/remediate explicitly.
|
||||
- Summary: `classifications.summary`, `text-sm`.
|
||||
- Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">`, one `<li>` per `reasons` array entry.
|
||||
- Recommended actions: informational chip row only (`StatusBadge` per action-status color, muted/slate tone since these aren't yet approved) — humanized labels (`block_sender` → "Block sender", `purge_message` → "Purge message", etc.). This card never renders checkboxes or editable params — that interactive surface lives entirely in `ActionAreaCard` below, so there is exactly one place an operator can approve an action, not two.
|
||||
- If `requires_approval === true`: an inline `Alert` (warning tone, amber): "This classification recommends a destructive action and requires explicit approval before remediation can proceed."
|
||||
- If no classification exists (only reachable via the D-08 standalone-report path): this card is omitted entirely — replaced by the D-08 `Alert` notice.
|
||||
|
||||
---
|
||||
|
||||
## Action Area Spec (REVIEW-05, D-03, D-04, D-05, D-06)
|
||||
|
||||
`ActionAreaCard` — the only interactive remediation surface on the page.
|
||||
|
||||
### Checkbox list (D-03)
|
||||
|
||||
One row per string in the latest classification's `recommendedActions` (`string[]`, e.g. `['block_sender', 'purge_message']`). Each row:
|
||||
|
||||
```
|
||||
[ ] Block sender [collapsed/expanded params ▾]
|
||||
Sender email: [__________________] (pre-filled, editable)
|
||||
```
|
||||
|
||||
- Checkbox label = humanized action type (see mapping table below).
|
||||
- Params form renders **always visible** under each row (not gated on the checkbox being checked) so the operator can review defaults before deciding what to check — but only checked rows' params are included in the submitted `POST /approve` body.
|
||||
- No recommended-action default params exist in the backend today (`recommended_actions` is a bare `string[]` — confirmed via `lib/services/campaign-classifier.ts`). This UI-SPEC prescribes the **client-side default-param derivation** the executor must implement, sourced from already-fetched campaign evidence:
|
||||
|
||||
| Action type | Default params (derived client-side) | Editable fields |
|
||||
|---|---|---|
|
||||
| `no_action` | `{}` | none — render "No parameters — informational verdict, no remediation needed." instead of a form |
|
||||
| `warn_user` | `{ recipientEmail: <primary report's requesterEmail>, message: '' }` | recipientEmail (Input), message (Textarea, placeholder: default warning copy) |
|
||||
| `block_sender` | `{ senderEmail: <messages.headers.from>, senderDomain: <derived domain> }` | senderEmail (Input), senderDomain (Input) |
|
||||
| `purge_message` | `{ messageId: <messages.message_id>, mailboxes: [] }` | messageId (Input, pre-filled), mailboxes (Input, comma-separated, empty by default — helper text: "Enter affected mailboxes, comma-separated") |
|
||||
| `reset_password` | `{ userPrincipalName: <primary report's requesterEmail> }` | userPrincipalName (Input) |
|
||||
| `isolate_endpoint` | `{ deviceId: '' }` | deviceId (Input, empty — helper text: "No device identifier available from evidence — enter manually") |
|
||||
| `disable_forwarding_rule` | `{ userPrincipalName: <primary report's requesterEmail>, ruleName: '' }` | userPrincipalName (Input, pre-filled), ruleName (Input, empty) |
|
||||
|
||||
Submission produces exactly `ApproveActionInput[]` (`{ actionType, params }`) matching `lib/services/remediation-service.ts` — one entry per checked row, `params` = the current (possibly edited) form values for that row.
|
||||
|
||||
### Buttons
|
||||
|
||||
Three buttons, always rendered (never removed from the DOM, per D-05), each disabled with a `Tooltip` explanation when a blocking condition applies. Evaluate conditions **in this priority order** per button — the first matching reason is the tooltip shown:
|
||||
|
||||
**"Approve selected"** (primary variant):
|
||||
1. No `approve` permission → "Requires approve permission"
|
||||
2. Campaign resolved (see below) → resolved-state copy
|
||||
3. Zero checkboxes checked → "Select at least one action to approve"
|
||||
4. Otherwise enabled → `POST /approve { actions: [...] }` → on success: `toast.success`, refetch campaign detail (D-04, no optimistic mutation)
|
||||
|
||||
**"Remediate approved actions"** (outline variant):
|
||||
1. No `remediate` permission → "Requires remediate permission"
|
||||
2. Campaign resolved → resolved-state copy
|
||||
3. No `remediation_actions` row with `status === 'approved'` → "No approved actions to remediate"
|
||||
4. Otherwise enabled → opens `AlertDialog` confirmation (copy above) → `POST /remediate` (no body) → on success: `toast.success`, refetch
|
||||
|
||||
**"Mark as false positive"** (destructive variant):
|
||||
1. No `approve` permission (mark-false-positive is gated on `approve` per the canonical route) → "Requires approve permission"
|
||||
2. Campaign resolved → resolved-state copy
|
||||
3. Any `remediation_actions` row with `status` in `('approved','completed')` → "Cannot mark false positive — this campaign already has approved or completed remediation" (mirrors the API's D-04 409 guard exactly — client-side check must not diverge from server behavior)
|
||||
4. Otherwise enabled → opens `AlertDialog` with an optional reason `Textarea` → `POST /mark-false-positive { reason? }` → on success: `toast.success`, refetch
|
||||
|
||||
**"Resolved" definition (D-05):** `campaigns.status === 'false_positive'` OR any `remediation_actions.status === 'completed'`. Resolved-state tooltip copy:
|
||||
- Remediated: "Already remediated on {completed_at date} by {approved_by}"
|
||||
- False positive: "Marked as false positive on {campaigns.updated_at date}"
|
||||
|
||||
Permission checks use `hasPermission(session.user.role, 'phishing', 'approve' | 'remediate')` from `lib/permissions.ts`, called client-side via `useSession()` (`lib/auth-client.ts`) — the exact function the server-side routes already use (D-06/REVIEW-06 — no separate or relaxed check).
|
||||
|
||||
---
|
||||
|
||||
## Timeline Spec (REVIEW-02)
|
||||
|
||||
`TimelineCard` — a single chronological (ascending, oldest-first — matches the existing `reports ... ORDER BY created_at ASC` convention in `app/api/phishing/campaigns/[id]/route.ts`) vertical list merging three sources client-side (or server-merged if the extended detail endpoint sorts them — planner's call per CONTEXT.md discretion):
|
||||
|
||||
1. **Reports** — one entry per linked report: "Report linked — Ticket #{ticketNumber} ({companyName})", icon `FileText` (muted).
|
||||
2. **Classifications** — one entry per classification history row: "Classified as {verdict} ({confidence}%)", icon `Sparkles`, tinted per verdict color (color table above).
|
||||
3. **Audit events** — one entry per `audit_events` row, `event_type` mapped to icon + label:
|
||||
|
||||
| `event_type` | Label | Icon | Tint |
|
||||
|---|---|---|---|
|
||||
| `remediation_approved` | "{N} action(s) approved by {actor}" | `CheckCircle2` | blue |
|
||||
| `remediation_completed` | "Remediation completed" | `ShieldCheck` | green |
|
||||
| `campaign_marked_false_positive` | "Marked as false positive" | `XCircle` | slate |
|
||||
| (any other/future value) | humanized `event_type` (snake_case → Title Case) fallback | `Circle` | muted |
|
||||
|
||||
Visual: left rail with an 8px dot (`h-2 w-2 rounded-full`, tinted per row) connected by a 1px `border-l`, right side: label (`text-sm`) + timestamp (`font-mono text-xs text-muted-foreground`, relative with the absolute value in a `title` attribute) + actor when present (`text-xs text-muted-foreground`). No new "Timeline" primitive needed — build with plain `div`s and Tailwind, this is a one-off layout, not a reusable pattern elsewhere in Pulse yet.
|
||||
|
||||
---
|
||||
|
||||
## Empty & Error States
|
||||
|
||||
| State | Trigger | Rendering |
|
||||
|---|---|---|
|
||||
| Loading | initial fetch in flight | `SkeletonHeader` + 3× `SkeletonCard` (`components/ui/skeleton-helpers.tsx`) in place of the page body |
|
||||
| D-07 — not yet triaged | ticket→campaign resolution finds no `reports` row for this `ticket_id` | Full-width `EmptyState` (`size="md"`) — icon `SearchX`, title/body/CTA per Copywriting Contract. Not a 404 — the page still renders its normal `PageHeader` shell. |
|
||||
| D-08 — ungrouped report | `reports` row exists, `campaign_id IS NULL` | `Alert` (info tone) with the D-08 copy, followed by the `EvidenceCard` alone (no `ClassificationCard`, `ActionAreaCard`, or `TimelineCard` — there is nothing to show for those yet) |
|
||||
| Load error | fetch throws / non-2xx | `Alert` (destructive tone) with the error copy + "Retry" button that re-runs the fetch |
|
||||
| Campaigns list empty | `GET /api/phishing/campaigns` returns `items: []` | `EmptyState` in place of `DataTable`, per Campaigns list page section above |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
Loading…
Add table
Add a link
Reference in a new issue