fix(22): resolve checker blockers (unclassified default state, LiveLink id gate)
This commit is contained in:
parent
b4707ce962
commit
476eb017eb
5 changed files with 97 additions and 24 deletions
|
|
@ -77,7 +77,7 @@ Audit event_type → label/icon/tint (UI-SPEC Timeline Spec):
|
||||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Classification Display Spec)
|
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Classification Display Spec)
|
||||||
</read_first>
|
</read_first>
|
||||||
<action>
|
<action>
|
||||||
Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in `<Card>` with `<CardTitle className="font-bold">` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">` one `<li>` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone `<Alert>`: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component renders nothing/omits itself when `classification` is null (the D-08 path handles that upstream).
|
Create `components/phishing/classification-card.tsx` (`'use client'`) exporting `ClassificationCard({ campaignId, classification, onReclassified })`. Wrap in `<Card>` with `<CardTitle className="font-bold">` + leading lucide `Sparkles` (`h-4 w-4 mr-2 inline`) reading "Classification". Header row: verdict StatusBadge (per recipe above) + "{confidence}% confidence" inline + a right-aligned "Reclassify ticket" button (`variant="outline"` `size="sm"`) that is HIDDEN (not disabled) unless `hasPermission(role, 'phishing', 'analyze')` where role comes from `useSession()` (`(session?.user as {role?:string})?.role ?? 'user'`); on click it POSTs `/api/phishing/campaigns/${campaignId}/classify` then toasts and calls `onReclassified()`. Summary: `classification.summary` as `text-sm`. Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">` one `<li>` per reasons entry. Recommended actions: an informational chip row of humanized labels (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule") as slate/muted StatusBadges — NO checkboxes or editable params here (that surface lives only in ActionAreaCard). If `requiresApproval === true`, render an inline amber-tone `<Alert>`: "This classification recommends a destructive action and requires explicit approval before remediation can proceed." This component returns `null` (renders nothing) when `classification` is null — it does NOT attempt to render a verdict/reasons/actions for a missing classification. Two upstream paths pass a null classification, and plan 06 (not this card) renders the replacement UI: (a) a grouped-but-not-yet-classified campaign — the DEFAULT state, since classification is never auto-triggered by the detection/grouping pipeline — where plan 06 renders a "Classify this campaign" empty-state CTA; and (b) the D-08 ungrouped-report path, where plan 06 renders the D-08 Alert. This card must therefore guard on `classification == null` at the top and return null (never dereference `classification.verdict`/`.reasons`/etc.).
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>grep -q "hasPermission" components/phishing/classification-card.tsx && grep -q "font-bold" components/phishing/classification-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "classification-card" | grep -qx 0 && echo OK</automated>
|
<automated>grep -q "hasPermission" components/phishing/classification-card.tsx && grep -q "font-bold" components/phishing/classification-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "classification-card" | grep -qx 0 && echo OK</automated>
|
||||||
|
|
@ -86,6 +86,7 @@ Audit event_type → label/icon/tint (UI-SPEC Timeline Spec):
|
||||||
- `grep -q "requiresApproval" components/phishing/classification-card.tsx` succeeds and an Alert renders on that branch
|
- `grep -q "requiresApproval" components/phishing/classification-card.tsx` succeeds and an Alert renders on that branch
|
||||||
- `grep -q "hasPermission(role, 'phishing', 'analyze')" components/phishing/classification-card.tsx` (or equivalent call) — Reclassify gated on analyze
|
- `grep -q "hasPermission(role, 'phishing', 'analyze')" components/phishing/classification-card.tsx` (or equivalent call) — Reclassify gated on analyze
|
||||||
- `grep -c "Checkbox" components/phishing/classification-card.tsx` returns 0 (no approve surface here)
|
- `grep -c "Checkbox" components/phishing/classification-card.tsx` returns 0 (no approve surface here)
|
||||||
|
- The component early-returns `null` when `classification == null` (no dereference of `classification.verdict`/`.reasons`) — verified by presence of a null/falsy guard before any `classification.` access
|
||||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||||
</acceptance_criteria>
|
</acceptance_criteria>
|
||||||
<done>ClassificationCard displays verdict/confidence/summary/reasons/recommended-action chips + approval warning; Reclassify gated on analyze permission.</done>
|
<done>ClassificationCard displays verdict/confidence/summary/reasons/recommended-action chips + approval warning; Reclassify gated on analyze permission.</done>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ must_haves:
|
||||||
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
|
- "Operator selects recommended action(s) via checkboxes, edits each action's pre-filled params inline, and submits all checked actions in one POST /approve call"
|
||||||
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
|
- "Remediate and mark-false-positive call their existing Phase 20 routes and trigger a refetch on success (no optimistic mutation)"
|
||||||
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
|
- "Approve/remediate/mark-false-positive buttons are disabled with a tooltip when the operator lacks the exact permission the API enforces, or when the campaign is already resolved"
|
||||||
|
- "When classification is null (grouped-but-unclassified default state), ActionAreaCard renders an informational note and no action rows/buttons instead of dereferencing recommendedActions — it never crashes on the initial unclassified state"
|
||||||
artifacts:
|
artifacts:
|
||||||
- path: "components/phishing/action-area-card.tsx"
|
- path: "components/phishing/action-area-card.tsx"
|
||||||
provides: "the only interactive remediation surface (REVIEW-05, D-03..06)"
|
provides: "the only interactive remediation surface (REVIEW-05, D-03..06)"
|
||||||
|
|
@ -79,13 +80,14 @@ Analog to copy: components/rmm/rmm-dispatch-dialog.tsx (useSession + disabled-bu
|
||||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules)
|
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Action Area Spec — checkbox list, 7-row param table, "Approve selected" button rules)
|
||||||
</read_first>
|
</read_first>
|
||||||
<action>
|
<action>
|
||||||
Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `<Card>` with `<CardTitle className="font-bold">` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). Render one row per string in `classification.recommendedActions`: a shadcn `<Checkbox>` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
|
Create `components/phishing/action-area-card.tsx` (`'use client'`) exporting `ActionAreaCard(props)`. Wrap in a shadcn `<Card>` with `<CardTitle className="font-bold">` + leading lucide `ShieldAlert` (`h-4 w-4 mr-2 inline`). NULL-GUARD FIRST: the `classification` prop is `Classification | null`; the grouped-but-not-yet-classified campaign (the DEFAULT state, since classification is never auto-triggered) passes `null` here. Immediately after the card header, if `classification == null` (or `classification.recommendedActions` is empty), render an informational `text-sm text-muted-foreground` note ("No classification yet — classify this campaign to see recommended remediation actions.") and return early WITHOUT rendering the checkbox list or any action buttons. Never dereference `classification.recommendedActions` before this guard. Plan 06 additionally avoids rendering ActionAreaCard at all in the unclassified state, so this guard is defense-in-depth. When `classification != null`, render one row per string in `classification.recommendedActions`: a shadcn `<Checkbox>` with a humanized label (block_sender→"Block sender", purge_message→"Purge message", warn_user→"Warn user", no_action→"No action", reset_password→"Reset password", isolate_endpoint→"Isolate endpoint", disable_forwarding_rule→"Disable forwarding rule"), plus an ALWAYS-VISIBLE params form beneath it (not gated on the checkbox) whose fields are pre-filled via `deriveDefaultParams(actionType, evidence)` and are editable per the UI-SPEC 7-row table: no_action renders "No parameters — informational verdict, no remediation needed." text instead of a form; warn_user→recipientEmail Input + message Textarea; block_sender→senderEmail + senderDomain Inputs; purge_message→messageId Input + mailboxes comma-separated Input (helper "Enter affected mailboxes, comma-separated"); reset_password→userPrincipalName Input; isolate_endpoint→deviceId Input (helper "No device identifier available from evidence — enter manually"); disable_forwarding_rule→userPrincipalName + ruleName Inputs. Hold per-row checked state and per-row edited params in `useState`. Add the "Approve selected" button (primary variant) that submits — enablement/tooltip gating comes in task 2, but wire the submit handler now: it POSTs `/api/phishing/campaigns/${campaignId}/approve` with body `{ actions: checkedRows.map(r => ({ actionType: r.actionType, params: r.currentParams })) }` (exactly ApproveActionInput[]; for purge_message parse the mailboxes comma-separated string into a string[] before submit), then on 2xx `toast.success` with the returned count and calls `props.onActionComplete()` (D-04 refetch — never mutate local state optimistically); on error `toast.error('Approve failed: {message}')`. Copy the fetch-POST-toast try/catch structure from rmm-dispatch-dialog.tsx.
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
|
<automated>grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx && grep -q "onActionComplete" components/phishing/action-area-card.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "action-area-card" | grep -qx 0 && echo OK</automated>
|
||||||
</verify>
|
</verify>
|
||||||
<acceptance_criteria>
|
<acceptance_criteria>
|
||||||
- `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds
|
- `grep -q "deriveDefaultParams" components/phishing/action-area-card.tsx` succeeds
|
||||||
|
- A null-guard on `classification` precedes any `classification.recommendedActions` access (component returns an informational note, not a `TypeError`, when classification is null) — `grep -Eq "classification == null|!classification" components/phishing/action-area-card.tsx`
|
||||||
- `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params`
|
- `grep -q "/approve" components/phishing/action-area-card.tsx` succeeds and the POST body uses `actions:` with `actionType`/`params`
|
||||||
- `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic)
|
- `grep -q "onActionComplete" components/phishing/action-area-card.tsx` succeeds (refetch, not optimistic)
|
||||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,17 @@ files_modified:
|
||||||
- app/phishing/tickets/[ticketId]/page.tsx
|
- app/phishing/tickets/[ticketId]/page.tsx
|
||||||
- app/phishing/page.tsx
|
- app/phishing/page.tsx
|
||||||
- components/navigation/app-navigation.tsx
|
- components/navigation/app-navigation.tsx
|
||||||
autonomous: true
|
autonomous: false
|
||||||
requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
|
requirements: [REVIEW-01, REVIEW-05, REVIEW-06]
|
||||||
must_haves:
|
must_haves:
|
||||||
truths:
|
truths:
|
||||||
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
|
- "Visiting /phishing/tickets/{ticketId} resolves the ticket to its campaign and renders the review page using the existing Better Auth session — no token/query-param auth"
|
||||||
- "The review page renders classification, action area, evidence, and timeline for a grouped campaign; a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
|
- "The review page renders classification, action area, evidence, and timeline for a CLASSIFIED grouped campaign; a 'Not yet classified' empty state with a 'Classify this campaign' CTA for a grouped-but-unclassified campaign (the default post-grouping state); a 'Not yet triaged' empty state with an Analyze CTA for D-07; a standalone-report notice + evidence for D-08; and an error+Retry for load failures"
|
||||||
- "After any approve/remediate/mark-false-positive succeeds, the page refetches and re-renders from fresh server state"
|
- "A grouped campaign with zero classifications (the default state right after detection/grouping, since classification is never auto-triggered) never crashes — ClassificationCard and ActionAreaCard are not rendered, a Classify CTA (POST /classify) is offered instead, and evidence + timeline still display"
|
||||||
|
- "After any approve/remediate/mark-false-positive/classify/analyze succeeds, the page refetches and re-renders from fresh server state"
|
||||||
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
|
- "Visiting /phishing lists recent campaigns and clicking a row navigates to /phishing/tickets/{firstReportTicketId}"
|
||||||
- "A 'Phishing' nav entry links to /phishing"
|
- "A 'Phishing' nav entry links to /phishing"
|
||||||
|
- "Phase completion is gated on a manual confirmation that a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the documented ticket_number fallback is applied)"
|
||||||
artifacts:
|
artifacts:
|
||||||
- path: "app/phishing/tickets/[ticketId]/page.tsx"
|
- path: "app/phishing/tickets/[ticketId]/page.tsx"
|
||||||
provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)"
|
provides: "the ticket-scoped LiveLink review page (REVIEW-01..06)"
|
||||||
|
|
@ -32,6 +34,10 @@ must_haves:
|
||||||
to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}"
|
to: "/api/phishing/tickets/{ticketId}/campaign then /api/phishing/campaigns/{id}"
|
||||||
via: "two-step fetch in load()"
|
via: "two-step fetch in load()"
|
||||||
pattern: "tickets/.+/campaign"
|
pattern: "tickets/.+/campaign"
|
||||||
|
- from: "app/phishing/tickets/[ticketId]/page.tsx"
|
||||||
|
to: "/api/phishing/campaigns/{id}/classify"
|
||||||
|
via: "Classify CTA in the grouped-but-unclassified branch"
|
||||||
|
pattern: "campaigns/.+/classify"
|
||||||
- from: "app/phishing/page.tsx"
|
- from: "app/phishing/page.tsx"
|
||||||
to: "/phishing/tickets/{firstReportTicketId}"
|
to: "/phishing/tickets/{firstReportTicketId}"
|
||||||
via: "router.push on row click"
|
via: "router.push on row click"
|
||||||
|
|
@ -39,10 +45,10 @@ must_haves:
|
||||||
---
|
---
|
||||||
|
|
||||||
<objective>
|
<objective>
|
||||||
Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a five-state machine (loading / not-triaged / ungrouped / ready / error), composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable.
|
Assemble the two pages and the nav entry. The ticket-scoped review page (`/phishing/tickets/{ticketId}`) is the LiveLink target: it resolves ticket→campaign, drives a state machine (loading / not-triaged / ungrouped / ready / error) where the `ready` render additionally branches on whether the grouped campaign has been classified yet, composes the plan-03/04/05 cards, and refetches after every action (D-04). The minimal campaigns list page (`/phishing`, D-00) gives the nav somewhere to point and lets operators browse into the review page. A flat "Phishing" nav item (D-02) makes it discoverable. A final blocking manual-verification checkpoint confirms the LiveLink numeric-ticket-id assumption against a real Autotask tenant (REVIEW-01, per 22-RESEARCH resolved Open Question).
|
||||||
|
|
||||||
Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces.
|
Purpose: This is the integration layer — every other plan's output is composed here into the operator-facing surfaces.
|
||||||
Output: 2 pages + 1 nav edit.
|
Output: 2 pages + 1 nav edit + 1 manual-verification gate.
|
||||||
</objective>
|
</objective>
|
||||||
|
|
||||||
<execution_context>
|
<execution_context>
|
||||||
|
|
@ -55,11 +61,20 @@ Output: 2 pages + 1 nav edit.
|
||||||
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
@.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-PATTERNS.md
|
||||||
|
|
||||||
<interfaces>
|
<interfaces>
|
||||||
Routes consumed (plan 02):
|
Routes consumed (plan 02 + existing):
|
||||||
GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? }
|
GET /api/phishing/tickets/{ticketId}/campaign -> { found:boolean; reportId?; campaignId?:string|null; ticketNumber? }
|
||||||
GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports + messages(headers/urls/attachments/bodyPreview) + indicators + classifications + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
|
GET /api/phishing/campaigns/{id} -> extended detail (campaign fields + reports(created_at ASC) + messages(headers/urls/attachments/bodyPreview) + indicators + classifications(created_at DESC) + remediationActions(+completedAt) + auditEvents + blastRadius + timeline)
|
||||||
GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset }
|
GET /api/phishing/campaigns?limit&offset -> { items:[{ id, campaignKey, status, reportCount, firstSeenAt, lastSeenAt, firstReportTicketId }], total, limit, offset }
|
||||||
POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA)
|
POST /api/phishing/tickets/{ticketId}/analyze (D-07 CTA — detect+parse+group; does NOT classify)
|
||||||
|
POST /api/phishing/campaigns/{id}/classify (grouped-but-unclassified CTA + ClassificationCard Reclassify — the ONLY trigger of classifyCampaign; detection/grouping never calls it)
|
||||||
|
|
||||||
|
Latest-classification derivation: the detail route returns `classifications` (plural, created_at DESC). The single latest classification the cards consume is `campaignDetail.classifications[0] ?? null`. A newly-grouped campaign has `classifications: []` → `null`.
|
||||||
|
|
||||||
|
Card signatures (plans 03/04/05):
|
||||||
|
ClassificationCard({ campaignId, classification, onReclassified })
|
||||||
|
ActionAreaCard({ campaignId, classification, remediationActions, campaignStatus, campaignUpdatedAt, evidence:{requesterEmail,senderEmail,senderDomain,messageId}, onActionComplete })
|
||||||
|
EvidenceCard({ messages, blastRadius })
|
||||||
|
TimelineCard({ timeline })
|
||||||
|
|
||||||
Components consumed:
|
Components consumed:
|
||||||
components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05)
|
components/phishing/{classification-card,action-area-card,evidence-card,timeline-card}.tsx (plans 03/04/05)
|
||||||
|
|
@ -77,25 +92,45 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
|
||||||
<files>app/phishing/tickets/[ticketId]/page.tsx</files>
|
<files>app/phishing/tickets/[ticketId]/page.tsx</files>
|
||||||
<read_first>
|
<read_first>
|
||||||
- app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom)
|
- app/analyzer/reports/[id]/page.tsx (use(params) + fetch + loading/error early-return + Card-stack layout idiom)
|
||||||
- components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here)
|
- components/phishing/classification-card.tsx, action-area-card.tsx, evidence-card.tsx, timeline-card.tsx (plans 03/04/05 — the cards composed here + their exact prop signatures)
|
||||||
|
- lib/auth-client.ts (useSession) + lib/permissions.ts (hasPermission — for gating the Classify CTA on phishing:analyze, mirroring the Reclassify button)
|
||||||
- components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx
|
- components/ui/empty-state.tsx, components/ui/alert.tsx, components/ui/skeleton-helpers.tsx, components/navigation/page-header.tsx
|
||||||
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States, Copywriting Contract)
|
- .planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md (Ticket-scoped review page layout order, Empty & Error States — INCLUDING the "Grouped, not yet classified" row, Copywriting Contract — INCLUDING the "Not yet classified" copy)
|
||||||
</read_first>
|
</read_first>
|
||||||
<action>
|
<action>
|
||||||
Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `<PageHeader title="Ticket #{ticketNumber}" description={humanized campaign status} breadcrumbs={['Phishing','Ticket #{ticketNumber}']} />` inside `<main className="container mx-auto px-6 py-6 space-y-6">` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`. Render per state: loading → `SkeletonHeader` + 3× `SkeletonCard`; not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()); ungrouped (D-08) → info-tone `<Alert>` with the D-08 copy followed by `<EvidenceCard>` alone (no classification/action/timeline cards); error → destructive `<Alert>` with the error copy + "Retry" button re-running load(); ready → in order: `<ClassificationCard>` (full width), `<ActionAreaCard onActionComplete={load}>` (full width, pass campaignId/classification/remediationActions/campaignStatus/campaignUpdatedAt and the derived evidence object {requesterEmail,senderEmail,senderDomain,messageId}), then `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard/><TimelineCard/></div>`. Pass `load` as `onActionComplete`/`onReclassified` so every action refetches (D-04, no optimistic mutation). Derive the ActionAreaCard `evidence` fields from the campaign detail's primary message headers + primary report requesterEmail. Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
|
Create `app/phishing/tickets/[ticketId]/page.tsx` as a `'use client'` page: `export default function TicketReviewPage({ params }: { params: Promise<{ ticketId: string }> }) { const { ticketId } = use(params); ... }`. Use the full chrome shell (D-01): `<PageHeader title="Ticket #{ticketNumber}" description={humanized campaign status} breadcrumbs={['Phishing','Ticket #{ticketNumber}']} />` inside `<main className="container mx-auto px-6 py-6 space-y-6">` (AppNavigation is supplied by the layout). Implement a state machine `'loading'|'not-triaged'|'ungrouped'|'ready'|'error'` with an async `load()`: fetch `/api/phishing/tickets/${ticketId}/campaign`; if `!found` → 'not-triaged'; if `campaignId == null` → 'ungrouped' (fetch that report's evidence for the standalone EvidenceCard); else fetch `/api/phishing/campaigns/${campaignId}` and → 'ready' (throw on non-2xx → 'error'). `useEffect(() => { void load(); }, [ticketId])`.
|
||||||
|
|
||||||
|
Render per state:
|
||||||
|
- loading → `SkeletonHeader` + 3× `SkeletonCard`.
|
||||||
|
- not-triaged (D-07) → full-width `EmptyState` (icon SearchX, title "Not yet triaged", body + CTA "Analyze this ticket" per Copywriting Contract that POSTs `/api/phishing/tickets/${ticketId}/analyze` then re-runs load()).
|
||||||
|
- ungrouped (D-08) → info-tone `<Alert>` with the D-08 copy followed by `<EvidenceCard messages={standaloneMessages} blastRadius={...} />` alone (no classification/action/timeline cards).
|
||||||
|
- error → destructive `<Alert>` with the error copy + "Retry" button re-running load().
|
||||||
|
- ready → FIRST derive the single latest classification: `const classification = campaignDetail.classifications[0] ?? null;` (the detail route returns `classifications` DESC-ordered; a freshly-grouped campaign returns `[]` → null). BRANCH on `classification`:
|
||||||
|
(a) `classification == null` (grouped-but-not-yet-classified — the DEFAULT state, because detection/grouping never auto-triggers classification): render a full-width `<EmptyState>` (icon Sparkles, title "Not yet classified", body per Copywriting Contract, CTA "Classify this campaign" that POSTs `/api/phishing/campaigns/${campaignId}/classify` then re-runs load()). Gate the CTA button on `hasPermission(role, 'phishing', 'analyze')` (role from `useSession()`, `(session?.user as {role?:string})?.role ?? 'user'`) — HIDDEN when absent, mirroring ClassificationCard's Reclassify gate; when hidden, still show the empty-state text so a read-only operator understands the state. Do NOT render `<ClassificationCard>` or `<ActionAreaCard>` in this branch (both require a non-null classification). Then render the read-only detail below: `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard messages={campaignDetail.messages} blastRadius={campaignDetail.blastRadius} /><TimelineCard timeline={campaignDetail.timeline} /></div>` (evidence + timeline exist even without a classification).
|
||||||
|
(b) `classification != null`: render in order, enumerating props explicitly for ALL four cards:
|
||||||
|
`<ClassificationCard campaignId={campaignId} classification={classification} onReclassified={load} />` (full width),
|
||||||
|
`<ActionAreaCard campaignId={campaignId} classification={classification} remediationActions={campaignDetail.remediationActions} campaignStatus={campaignDetail.status} campaignUpdatedAt={campaignDetail.updatedAt} evidence={evidence} onActionComplete={load} />` (full width),
|
||||||
|
then `<div className="grid gap-6 lg:grid-cols-2"><EvidenceCard messages={campaignDetail.messages} blastRadius={campaignDetail.blastRadius} /><TimelineCard timeline={campaignDetail.timeline} /></div>`.
|
||||||
|
|
||||||
|
Derive the ActionAreaCard `evidence` object from the PRIMARY (earliest) report + its message — use `campaignDetail.reports[0]` (same `reports[0]`/earliest-report definition as plan 22-02's server-side `blastRadius` derivation, which orders reports `created_at ASC`, so the client evidence object and the server blast-radius agree on which report is "primary") and its message headers: `{ requesterEmail: reports[0].requesterEmail, senderEmail: primaryMessage.headers.from.email, senderDomain: <derived from senderEmail>, messageId: primaryMessage.messageId }`.
|
||||||
|
|
||||||
|
Pass `load` as `onActionComplete`/`onReclassified` so every action (approve/remediate/mark-false-positive/reclassify/classify) refetches (D-04, no optimistic mutation). Authentication is the existing Better Auth session only (the route is not in middleware publicRoutes) — do NOT add any token/query-param auth (REVIEW-01).
|
||||||
</action>
|
</action>
|
||||||
<verify>
|
<verify>
|
||||||
<automated>grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK</automated>
|
<automated>grep -q "tickets/\${ticketId}/campaign" app/phishing/tickets/[ticketId]/page.tsx && grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx && grep -q "campaigns/\${campaignId}/classify" app/phishing/tickets/[ticketId]/page.tsx && grep -q "onActionComplete={load}" app/phishing/tickets/[ticketId]/page.tsx && npx tsc --noEmit --pretty 2>&1 | grep -c "tickets/\[ticketId\]/page" | grep -qx 0 && echo OK</automated>
|
||||||
</verify>
|
</verify>
|
||||||
<acceptance_criteria>
|
<acceptance_criteria>
|
||||||
- `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present)
|
- `grep -q "not-triaged" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "ungrouped" ...` both succeed (D-07/D-08 states present)
|
||||||
|
- `grep -q "classifications\[0\]" app/phishing/tickets/[ticketId]/page.tsx` succeeds (single latest classification derived as `classifications[0] ?? null`, Warning 3)
|
||||||
|
- `grep -q "Not yet classified" app/phishing/tickets/[ticketId]/page.tsx` and `grep -q "campaigns/\${campaignId}/classify" ...` both succeed (grouped-but-unclassified branch + Classify CTA — Blocker 1)
|
||||||
- `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA)
|
- `grep -q "/analyze" app/phishing/tickets/[ticketId]/page.tsx` succeeds (D-07 CTA)
|
||||||
- `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04)
|
- `grep -q "onActionComplete" app/phishing/tickets/[ticketId]/page.tsx` succeeds and points at the refetch function (D-04)
|
||||||
- `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01)
|
- `grep -c "searchParams\|token=" app/phishing/tickets/[ticketId]/page.tsx` returns 0 (no separate auth scheme — REVIEW-01)
|
||||||
- All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported and rendered
|
- ActionAreaCard is rendered ONLY in the `classification != null` branch (never for a null classification) — verified by the branch guard; and its props include `classification={classification}` (non-null in that branch)
|
||||||
|
- All four cards (ClassificationCard/ActionAreaCard/EvidenceCard/TimelineCard) are imported; each is rendered with an explicit prop list per the card signatures above (Warning 3)
|
||||||
- `npx tsc --noEmit --pretty` reports no error in this file
|
- `npx tsc --noEmit --pretty` reports no error in this file
|
||||||
</acceptance_criteria>
|
</acceptance_criteria>
|
||||||
<done>Review page resolves ticket→campaign, renders all four states + ready layout, refetches after every action, Better-Auth-session-only.</done>
|
<done>Review page resolves ticket→campaign; renders loading/not-triaged/ungrouped/error plus a `ready` render that branches into grouped-but-unclassified (Classify CTA + evidence/timeline, no crash) vs. classified (all four cards with explicit props); refetches after every action; Better-Auth-session-only.</done>
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
<task type="auto">
|
<task type="auto">
|
||||||
|
|
@ -124,6 +159,31 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
|
||||||
<done>Campaigns list page browses recent campaigns and navigates to the ticket-scoped review page; Phishing nav entry present for all roles.</done>
|
<done>Campaigns list page browses recent campaigns and navigates to the ticket-scoped review page; Phishing nav entry present for all roles.</done>
|
||||||
</task>
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 3: LiveLink numeric-ticket-id manual verification (REVIEW-01 / 22-RESEARCH resolved Open Question)</name>
|
||||||
|
<files>none (live verification only — no file changes unless the ticket_number fallback is needed, which edits lib/services/phishing-ticket-resolver.ts)</files>
|
||||||
|
<what-built>The ticket-scoped review page `/phishing/tickets/{ticketId}` (Task 1) assumes the URL segment is the numeric Autotask ticket id (`tickets.id` / `reports.ticket_id`), parsed via `Number(ticket_id)` in the plan-22-02 resolver route — consistent with the already-shipped Phase 18 `/api/phishing/tickets/{ticket_id}/analyze` route. 22-RESEARCH's Open Question on this was RESOLVED as a documented decision, gated on this manual confirmation (no live Autotask LiveLink admin access was available during research/planning).</what-built>
|
||||||
|
<action>
|
||||||
|
This is a blocking human-verification checkpoint — no code changes unless the fallback is triggered. The executor pauses here; a human confirms a real Autotask LiveLink click resolves against the numeric-ticket-id assumption, per how-to-verify. Do not auto-approve: the numeric-vs-ticket_number question could not be confirmed against a live Autotask tenant during planning, so a real click is the only way to close it. If confirmation fails (the segment is the human-readable ticket number), apply the documented one-line `ticket_number` fallback in `lib/services/phishing-ticket-resolver.ts` and re-verify.
|
||||||
|
</action>
|
||||||
|
<how-to-verify>
|
||||||
|
1. In Autotask, configure (or inspect an existing) LiveLink button targeting `${BETTER_AUTH_URL}/phishing/tickets/` followed by the ticket-id dynamic-content field — reuse the SAME dynamic field the Phase 18 `/analyze` LiveLink (if one exists) already uses.
|
||||||
|
2. From a real Autotask ticket that has a grouped phishing campaign, click the LiveLink button.
|
||||||
|
3. Confirm the browser lands on `/phishing/tickets/{numeric id}` and the page RESOLVES (renders the campaign/classification/empty-state) — it must NOT return a 400 "Invalid ticket_id" from the resolver route.
|
||||||
|
4. If instead the URL contains the human-readable ticket number (e.g. `T20260101.0001`) and the page 400s: apply the documented one-line fallback in `lib/services/phishing-ticket-resolver.ts` — look up via `reports.ticket_number` (already a stored column) when the segment is non-numeric — then re-run steps 2-3.
|
||||||
|
</how-to-verify>
|
||||||
|
<verify>
|
||||||
|
<human-check>A real Autotask LiveLink click on a ticket with a grouped campaign lands on `/phishing/tickets/{numeric id}` and the page resolves (renders campaign/classification/empty-state), not a 400 "Invalid ticket_id"; if it 400s on a ticket_number segment, the documented resolver fallback is applied and the click then resolves.</human-check>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- A real LiveLink click resolves to a rendered review page (not a 400) — OR the ticket_number fallback in phishing-ticket-resolver.ts was applied and the click then resolves.
|
||||||
|
- The dynamic-content field used in the LiveLink button is recorded (matches or diverges from Phase 18 /analyze's field).
|
||||||
|
- If deferred, the owner responsible for confirming before operator exposure is recorded.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>A real Autotask LiveLink click resolves to the review page under the numeric-ticket-id assumption (or the documented ticket_number fallback was applied and now resolves); human typed "verified" / "verified — fallback applied" / "deferred: {owner}".</done>
|
||||||
|
<resume-signal>Type "verified" once a real LiveLink click resolves correctly (or "verified — fallback applied" if the ticket_number fallback was needed and now works). If no Autotask tenant/LiveLink button is available yet, type "deferred: {owner}" to record who will confirm before the button is exposed to operators — the numeric-id assumption ships as-is per the RESOLVED research decision until then.</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
</tasks>
|
</tasks>
|
||||||
|
|
||||||
<threat_model>
|
<threat_model>
|
||||||
|
|
@ -139,19 +199,21 @@ Nav analog: components/navigation/app-navigation.tsx navigationItems — the PAX
|
||||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|-----------|----------|-----------|-------------|-----------------|
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added |
|
| T-22-16 | Spoofing / Elevation (unauthenticated LiveLink access) | both pages | mitigate | REVIEW-01 — existing Better Auth session only; /phishing absent from middleware publicRoutes so an unauth request redirects to sign-in; no token/query-param auth added |
|
||||||
| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the page adds no relaxed check and the server routes enforce independently |
|
| T-22-17 | Elevation of Privilege (action buttons for unauthorized role) | review page | mitigate | Action gating is delegated to ActionAreaCard's `hasPermission()` (plan 05); the Classify CTA is likewise gated on `phishing:analyze`; the page adds no relaxed check and the server routes enforce independently |
|
||||||
| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML |
|
| T-22-18 | Information Disclosure (rendering unsanitized evidence) | review page | mitigate | Evidence rendering is delegated to EvidenceCard (plan 03 — inert URLs, plain-text body); the page passes data through, never via dangerouslySetInnerHTML |
|
||||||
|
| T-22-19 | Denial of Service / broken-link (LiveLink id semantics mismatch) | resolver + review page | mitigate | Numeric-id assumption documented (RESOLVED research decision) and gated by the blocking manual-verification checkpoint; a one-line `ticket_number` fallback is pre-specified if confirmation fails |
|
||||||
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
|
| T-22-SC | Tampering | npm/pip/cargo installs | accept | No package-manager installs in this plan (22-RESEARCH — zero new packages) |
|
||||||
</threat_model>
|
</threat_model>
|
||||||
|
|
||||||
<verification>
|
<verification>
|
||||||
- `npx tsc --noEmit --pretty` clean
|
- `npx tsc --noEmit --pretty` clean
|
||||||
- `npm test` full suite green
|
- `npm test` full suite green
|
||||||
- Manual click-through (per 22-VALIDATION Manual-Only): review page in all four states (full-campaign, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
|
- Manual click-through (per 22-VALIDATION Manual-Only): review page in all states (classified full-campaign, grouped-but-not-yet-classified with Classify CTA, D-07 not-yet-triaged, D-08 ungrouped, load-error); approve→remediate→resolved flow; user-role sees disabled/hidden actions; blast-radius unavailable state with Mimecast unset; list page row-click navigates correctly
|
||||||
|
- Blocking manual-verification checkpoint (Task 3): a real Autotask LiveLink click resolves against the numeric-ticket-id assumption (or the ticket_number fallback is applied) — REVIEW-01 / 22-RESEARCH resolved Open Question
|
||||||
</verification>
|
</verification>
|
||||||
|
|
||||||
<success_criteria>
|
<success_criteria>
|
||||||
Both operator surfaces work end-to-end: the LiveLink review page (all states, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry.
|
Both operator surfaces work end-to-end: the LiveLink review page (all states including the default grouped-but-unclassified Classify CTA, actions with refetch, session-only auth) and the discoverable campaigns list page with a working nav entry; the LiveLink ticket-id addressing is confirmed (or its documented fallback applied) via the blocking checkpoint.
|
||||||
</success_criteria>
|
</success_criteria>
|
||||||
|
|
||||||
<output>
|
<output>
|
||||||
|
|
|
||||||
|
|
@ -414,12 +414,13 @@ Not applicable in the traditional sense (no external library version churn to tr
|
||||||
|
|
||||||
**If this table is empty:** N/A — see above, two low-to-moderate-risk assumptions logged.
|
**If this table is empty:** N/A — see above, two low-to-moderate-risk assumptions logged.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
1. **Does Autotask LiveLink actually pass the numeric ticket ID, or the ticket number?**
|
1. **Does Autotask LiveLink actually pass the numeric ticket ID, or the ticket number?** — **RESOLVED (documented decision + manual-verification gate).**
|
||||||
- What we know: The existing (already-shipped, Phase 18) `/api/phishing/tickets/{ticket_id}/analyze` route treats its `ticket_id` path param as the numeric `tickets.id`/AT entity ID, via `Number(ticket_id)`. ROADMAP.md and CONTEXT.md both describe LiveLink as supplying "the ticket ID" without specifying numeric-ID vs. ticket-number.
|
- What we know: The existing (already-shipped, Phase 18) `/api/phishing/tickets/{ticket_id}/analyze` route treats its `ticket_id` path param as the numeric `tickets.id`/AT entity ID, via `Number(ticket_id)`. ROADMAP.md and CONTEXT.md both describe LiveLink as supplying "the ticket ID" without specifying numeric-ID vs. ticket-number.
|
||||||
- What's unclear: No direct access to the Autotask LiveLink admin configuration UI was available this session to confirm which field a configured LiveLink button would interpolate into a target URL.
|
- What's unclear this session: No direct access to the Autotask LiveLink admin configuration UI was available to independently confirm which field a configured LiveLink button interpolates into a target URL.
|
||||||
- Recommendation: Follow the existing `analyze` route's precedent (numeric AT ID) for consistency — if it turns out wrong in a later integration/manual test, the fix is a one-line change (parse `ticket_number` string and look up via `reports.ticket_number` instead of `reports.ticket_id`, since `reports.ticket_number` is already a stored column). Flag this as a manual verification item for whoever configures the actual LiveLink button in Autotask (likely outside this phase's automated test surface entirely, since it requires a real Autotask tenant).
|
- **DECISION (2026-07-16):** Proceed with the numeric `tickets.id` / `reports.ticket_id` assumption for `/phishing/tickets/{ticketId}`, consistent with the already-shipped `/api/phishing/tickets/{ticket_id}/analyze` precedent. Rationale: (a) the `/analyze` route uses the identical `Number(ticket_id)` semantics and is already in production, so a LiveLink button targeting the new route can reuse the exact same dynamic-content field configuration; (b) if the assumption is wrong, the fix is a one-line change — parse the `ticket_number` string and look up via `reports.ticket_number` (already a stored column) instead of `reports.ticket_id` — localized entirely to `lib/services/phishing-ticket-resolver.ts` (plan 22-01) with no route/UI change.
|
||||||
|
- **GATE:** This decision is NOT verified against a live Autotask tenant this session. Phase 22 carries an explicit manual-verification checkpoint (plan 22-06, final task, `checkpoint:human-verify`, blocking) that gates phase completion: whoever configures the real Autotask LiveLink button must confirm a real click lands on `/phishing/tickets/{numeric id}` and resolves (not a 400). If the button instead interpolates `ticket_number`, apply the documented one-line resolver fallback before sign-off. This removes the open question as a silent risk: the assumption is documented and the confirmation is a named, blocking task rather than an unowned TODO.
|
||||||
|
|
||||||
## Environment Availability
|
## Environment Availability
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ Monospace (IBM Plex Mono, `font-mono`) is mandatory for: Message-ID, Return-Path
|
||||||
**Accent (`--primary`) reserved for — nothing else:**
|
**Accent (`--primary`) reserved for — nothing else:**
|
||||||
- The "Approve selected" primary submit button (only primary-filled button on the Action Area card)
|
- 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
|
- The "Analyze this ticket" CTA button in the D-07 not-yet-triaged empty state
|
||||||
|
- The "Classify this campaign" CTA button in the grouped-but-not-yet-classified empty state (see Empty & Error States)
|
||||||
- Focus rings on all inputs/buttons/checkboxes (existing global convention, unchanged)
|
- 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)
|
- The active-tab underline inside the Evidence card's tab strip (mirrors the existing top-nav active-state convention, `DESIGN.md` §4)
|
||||||
|
|
||||||
|
|
@ -139,6 +140,9 @@ Monospace (IBM Plex Mono, `font-mono`) is mandatory for: Message-ID, Return-Path
|
||||||
| 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 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-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." |
|
| 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." |
|
||||||
|
| Not-yet-classified empty state (grouped campaign, zero `classifications` rows) heading | "Not yet classified" |
|
||||||
|
| Not-yet-classified empty state body | "This campaign is grouped but hasn't been classified yet. Run classification to generate a verdict and recommended remediation actions." |
|
||||||
|
| Not-yet-classified empty state CTA | "Classify this campaign" → `POST /api/phishing/campaigns/{id}/classify`, then refetch (gated on `phishing:analyze`; hidden when absent, matching the Reclassify button) |
|
||||||
| Error state (load failure) | "Couldn't load this campaign. {error message} — try reloading the page." + "Retry" button |
|
| 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 (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." |
|
| Blast radius unavailable (lookup failed) | "Blast radius lookup failed: {error}. Classification proceeded without it." |
|
||||||
|
|
@ -293,7 +297,9 @@ explicit `aria-label` — never rely on the icon alone for accessibility.
|
||||||
- Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">`, one `<li>` per `reasons` array entry.
|
- 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.
|
- 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 `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.
|
- If no classification exists, this card is omitted entirely. There are two such paths, and the review page (not this card) chooses the replacement:
|
||||||
|
1. **Grouped campaign, zero `classifications` rows** — the default/initial state for every newly-detected campaign, because classification is never auto-triggered by the detection/grouping pipeline (webhook, sweep, and on-demand `/analyze` all group but do NOT call `classifyCampaign`; `POST /api/phishing/campaigns/{id}/classify` is the only trigger). The review page renders the "Not yet classified" empty state with a "Classify this campaign" CTA (see Empty & Error States and Copywriting Contract) in place of this card. This is the common case, not an edge case.
|
||||||
|
2. **Ungrouped standalone report (D-08)** — replaced by the D-08 `Alert` notice.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -382,6 +388,7 @@ Visual: left rail with an 8px dot (`h-2 w-2 rounded-full`, tinted per row) conne
|
||||||
| Loading | initial fetch in flight | `SkeletonHeader` + 3× `SkeletonCard` (`components/ui/skeleton-helpers.tsx`) in place of the page body |
|
| 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-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) |
|
| 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) |
|
||||||
|
| Grouped, not yet classified | campaign resolved (`campaign_id` set) but `classifications` array is empty — the DEFAULT state for a freshly-grouped campaign, since classification is never auto-triggered | `EmptyState` (`size="md"`, icon `Sparkles`, title/body/CTA per Copywriting Contract) in place of `ClassificationCard` + `ActionAreaCard`, followed by the read-only `EvidenceCard` + `TimelineCard` (evidence/timeline exist even without a classification). The CTA POSTs `POST /api/phishing/campaigns/{id}/classify` then refetches. Neither `ClassificationCard` nor `ActionAreaCard` is rendered in this state (both require a non-null classification). |
|
||||||
| Load error | fetch throws / non-2xx | `Alert` (destructive tone) with the error copy + "Retry" button that re-runs the fetch |
|
| 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 |
|
| Campaigns list empty | `GET /api/phishing/campaigns` returns `items: []` | `EmptyState` in place of `DataTable`, per Campaigns list page section above |
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue