chore: merge executor worktree (worktree-agent-afdf88c6384b105cc)
This commit is contained in:
commit
05a31d6a65
3 changed files with 374 additions and 0 deletions
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
phase: 23-classification-disposition-per-client-automation-gate
|
||||
plan: 04
|
||||
subsystem: ui
|
||||
tags: [nextjs, react, shadcn, admin-page, phishing-automation]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 23-classification-disposition-per-client-automation-gate
|
||||
provides: "Plan 03's phishing_automation_gate migration + admin GET/PATCH/DELETE API"
|
||||
provides:
|
||||
- /admin/phishing-automation page with searchable/filterable company table and three per-row Switch toggles (auto-parse, auto-classify, auto-report)
|
||||
- /admin index tile linking to the new page
|
||||
affects: [23-05-gated-webhook-chain]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [per-stage toggle(company, stage, next) sending all three current flags on every PATCH, cloned from client-scope's single-toggle pattern]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- app/admin/phishing-automation/page.tsx
|
||||
modified:
|
||||
- app/admin/page.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Used the Bot lucide icon for the Phishing Automation tile (ShieldCheck was already claimed by the Roles tile) to keep icons visually distinct within the Tools & Data section"
|
||||
- "Task 1's human-verify checkpoint was approved against a temporary rebuild of the production container rather than local `npm run dev`, per the user's own testing preference in this session — functionally equivalent verification of the same committed code"
|
||||
|
||||
patterns-established:
|
||||
- "Per-stage toggle(company, stage, next) helper: PATCHes all three current boolean flags (two unchanged + one toggled) rather than supporting partial-field updates, matching the Plan 03 API's all-fields-required PATCH contract"
|
||||
|
||||
requirements-completed: [AUTOGATE-02]
|
||||
|
||||
# Metrics
|
||||
duration: ~25min (across two sessions; Task 2 continuation session ~5min)
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 23 Plan 04: Admin Phishing-Automation Page + Index Tile Summary
|
||||
|
||||
**New `/admin/phishing-automation` page with a per-company table exposing three independent Switch toggles (auto-parse, auto-classify, auto-report), cloned from the `/admin/client-scope` structure, plus a discoverable tile on the `/admin` index.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~25 min total (Task 1 in prior session, Task 2 completed in this continuation session in ~5 min)
|
||||
- **Completed:** 2026-07-16
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 2 (1 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
- `/admin/phishing-automation` page renders a searchable/filterable company table with three per-row `Switch` toggles wired to the Plan 03 API (GET on load, PATCH on toggle, all three flags sent together)
|
||||
- Helper caption clarifies stage dependency (report meaningfully requires classify; classify requires parse) as informational guidance only — not enforced in the UI
|
||||
- `/admin` index now has a "Phishing Automation" tile in the Tools & Data section, next to Client Scope, linking to the new page
|
||||
- Human verification (Task 1 checkpoint) confirmed: table loads with three toggle columns all OFF by default, a toggle persists across reload via PATCH, and search/type filters work — tested against a temporary production container rebuild
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Build /admin/phishing-automation page (3-toggle company table)** - `5dabaad` (feat) — completed in prior session; checkpoint approved
|
||||
2. **Task 2: Add admin index tile for Phishing Automation** - `160fe88` (feat) — completed in this continuation session
|
||||
|
||||
_No TDD tasks in this plan — all tasks are `type="checkpoint:human-verify"` (Task 1) and `type="auto"` (Task 2)._
|
||||
|
||||
## Files Created/Modified
|
||||
- `app/admin/phishing-automation/page.tsx` (266 lines) - `'use client'` page with `Company` interface (`autoParse`/`autoClassify`/`autoReport` booleans), `load()` GET fetch, per-stage `toggle(company, stage, next)` PATCH, search + company-type filter chrome, three `<Switch>` cells per row with `aria-label`s, `PageHeader` describing the opt-in gate and acknowledge_user auto-post behavior
|
||||
- `app/admin/page.tsx` - Added `Bot` to the `lucide-react` import block; added a "Phishing Automation" tile object to the Tools & Data section's `tiles[]` array, immediately after "Client Scope"
|
||||
|
||||
## Decisions Made
|
||||
- Chose the `Bot` icon (not `ShieldCheck`, already used by the Roles tile) for visual distinction within the same admin section
|
||||
- Task 1's checkpoint verification was performed by the user against a temporary production container rebuild rather than `npm run dev` locally — same committed code, equivalent verification path; documented here since it deviates from the plan's literal `npm run dev` instruction
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written (Task 1's checkpoint verification method was a user-driven substitution of environment, not a code deviation).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- The admin control surface for the per-company automation gate (D-08) is complete and reachable from `/admin`
|
||||
- Plan 05 (gated webhook auto-pipeline) can now rely on admins having a UI to opt individual companies into auto-parse/auto-classify/auto-report
|
||||
- `npx tsc --noEmit --pretty` exits 0
|
||||
|
||||
---
|
||||
*Phase: 23-classification-disposition-per-client-automation-gate*
|
||||
*Completed: 2026-07-16*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: app/admin/phishing-automation/page.tsx
|
||||
- FOUND: app/admin/page.tsx
|
||||
- FOUND: commit 5dabaad (Task 1)
|
||||
- FOUND: commit 160fe88 (Task 2)
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
ShieldCheck,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Bot,
|
||||
DollarSign,
|
||||
CalendarClock,
|
||||
Building2,
|
||||
|
|
@ -244,6 +245,12 @@ export default function AdminIndexPage() {
|
|||
icon: Building2,
|
||||
description: 'Control which companies appear in dashboard KPIs and ticket analytics',
|
||||
},
|
||||
{
|
||||
title: 'Phishing Automation',
|
||||
href: '/admin/phishing-automation',
|
||||
icon: Bot,
|
||||
description: 'Per-company opt-in gate for automatic phishing parse / classify / acknowledge-note stages',
|
||||
},
|
||||
{
|
||||
title: 'RMM Overshell',
|
||||
href: '/admin/rmm-overshell',
|
||||
|
|
|
|||
266
app/admin/phishing-automation/page.tsx
Normal file
266
app/admin/phishing-automation/page.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Search, RefreshCw, ShieldCheck, Zap } from 'lucide-react';
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
companyName: string;
|
||||
companyType: number | null;
|
||||
companyTypeLabel: string | null;
|
||||
autoParse: boolean;
|
||||
autoClassify: boolean;
|
||||
autoReport: boolean;
|
||||
}
|
||||
|
||||
type Stage = 'autoParse' | 'autoClassify' | 'autoReport';
|
||||
|
||||
const STAGE_LABEL: Record<Stage, string> = {
|
||||
autoParse: 'Auto-parse',
|
||||
autoClassify: 'Auto-classify',
|
||||
autoReport: 'Auto-report',
|
||||
};
|
||||
|
||||
export default function PhishingAutomationPage() {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [enabled, setEnabled] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (typeFilter !== 'all') params.set('type', typeFilter);
|
||||
const qs = params.toString();
|
||||
const res = await fetch('/api/admin/phishing-automation' + (qs ? `?${qs}` : ''));
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(d.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { companies: Company[]; total: number; enabled: number };
|
||||
setCompanies(data.companies);
|
||||
setTotal(data.total);
|
||||
setEnabled(data.enabled);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, typeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function toggle(company: Company, stage: Stage, next: boolean) {
|
||||
setToggling(`${company.id}:${stage}`);
|
||||
const nextFlags = {
|
||||
autoParse: stage === 'autoParse' ? next : company.autoParse,
|
||||
autoClassify: stage === 'autoClassify' ? next : company.autoClassify,
|
||||
autoReport: stage === 'autoReport' ? next : company.autoReport,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/admin/phishing-automation/${company.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(nextFlags),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(d.error ?? `Failed: ${res.status}`);
|
||||
}
|
||||
setCompanies((prev) =>
|
||||
prev.map((c) => (c.id === company.id ? { ...c, ...nextFlags } : c))
|
||||
);
|
||||
setEnabled((n) => {
|
||||
const wasEnabled = company.autoParse || company.autoClassify || company.autoReport;
|
||||
const isEnabled = nextFlags.autoParse || nextFlags.autoClassify || nextFlags.autoReport;
|
||||
if (wasEnabled === isEnabled) return n;
|
||||
return isEnabled ? n + 1 : Math.max(0, n - 1);
|
||||
});
|
||||
toast.success(`${STAGE_LABEL[stage]} ${next ? 'enabled' : 'disabled'} for ${company.companyName}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Toggle failed');
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTypes = Array.from(new Set(companies.map((c) => c.companyType).filter(Boolean))).sort() as number[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Phishing Automation"
|
||||
description="Opt in each company to automatic phishing-report parsing, classification, and reporting. Auto-report only auto-posts the acknowledge_user thank-you note for User Awareness (confirmed phishing-simulation) verdicts — every other verdict still requires manual approval regardless of this setting."
|
||||
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Phishing Automation' }]}
|
||||
accent
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Total companies:</span>
|
||||
<span className="font-semibold num">{total}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-muted-foreground">With any stage enabled:</span>
|
||||
<span className="font-semibold num text-emerald-600 dark:text-emerald-400">{enabled}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stages are independent toggles, but they build on each other in practice: auto-report is only
|
||||
meaningful when auto-classify is also on, and auto-classify is only meaningful when auto-parse
|
||||
is also on. This is informational — the UI does not enforce or auto-cascade these dependencies.
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 p-4 border-b">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search companies…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{uniqueTypes.map((t) => (
|
||||
<SelectItem key={t} value={String(t)}>
|
||||
Type {t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{companies.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : companies.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
No companies match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Company</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-28 text-right">Auto-parse</TableHead>
|
||||
<TableHead className="w-28 text-right">Auto-classify</TableHead>
|
||||
<TableHead className="w-28 text-right">Auto-report</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.companyName}</TableCell>
|
||||
<TableCell>
|
||||
{company.companyTypeLabel ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{company.companyTypeLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Switch
|
||||
checked={company.autoParse}
|
||||
disabled={toggling === `${company.id}:autoParse`}
|
||||
onCheckedChange={(next) => void toggle(company, 'autoParse', next)}
|
||||
aria-label={`Auto-parse for ${company.companyName}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Switch
|
||||
checked={company.autoClassify}
|
||||
disabled={toggling === `${company.id}:autoClassify`}
|
||||
onCheckedChange={(next) => void toggle(company, 'autoClassify', next)}
|
||||
aria-label={`Auto-classify for ${company.companyName}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Switch
|
||||
checked={company.autoReport}
|
||||
disabled={toggling === `${company.id}:autoReport`}
|
||||
onCheckedChange={(next) => void toggle(company, 'autoReport', next)}
|
||||
aria-label={`Auto-report for ${company.companyName}`}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue