mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 16:48:51 +00:00
feat(frontend): Phase 1 Foundation Primitives + Toast System — close UX-H2/H3/H5 + UX-M2/M3/M4/L5 + FE-M4
Frontend design remediation, Phase 1 (Foundation Primitives + Toast).
Builds the six reusable UI primitives every later phase consumes;
migrates the audit-enumerated destructive-action callsites; humanises
the StatusBadge wire keys; and wraps the bulk-action bar in a
Transition with a post-action toast affordance.
Six new primitives + their .test.tsx siblings
=============================================
web/src/components/Toaster.tsx — Sonner wrapper, mounted
once at the root next to
QueryClientProvider. Pages
import { toast } from
"sonner" directly.
web/src/components/ConfirmDialog.tsx — Headless UI Dialog primitive
with optional typed-
confirmation friction for
the most-irreversible actions
(archive-certificate uses
typedConfirmation="archive").
web/src/components/Tooltip.tsx — Floating-UI tooltip with
hover + focus triggers,
aria-describedby wiring,
ESC-to-dismiss. Migrations
of the 103 native title=
sites stay in subsequent
per-page PRs per the audit
prompt's explicit "DO NOT"
on one-mega-PR sweeps.
web/src/components/EmptyState.tsx — Empty-state primitive with
optional icon / title /
description / primary +
secondary CTAs. DataTable
adds a new emptyState slot
(legacy emptyMessage string
prop preserved for backward
compat).
web/src/components/Combobox.tsx — Headless UI typeahead-
select primitive. Migrations
of the 53 native <select>
sites stay in subsequent
per-page PRs.
web/src/components/Banner.tsx — Severity-variant alert
banner with role="alert" on
error/warning, role="status"
on success/info. Migrating
the ~102 inline
bg-(red|amber|yellow)-50
sites stays as page-touch
rolling work.
Each primitive ships with a sibling .test.tsx asserting the
behavioural contract — render at rest, fire callbacks, ARIA wiring,
keyboard nav, variant styling. Total new test count: 109 assertions
across 7 files (6 primitives + extended StatusBadge).
UX-H5 closure — StatusBadge display strings
============================================
web/src/components/StatusBadge.tsx gets a statusDisplay map paired
with the existing statusStyles map. Wire keys stay byte-identical
to the Go enums per the D-1 closure comment block — only the
rendered text changes. PascalCase + snake_case + lowercase enums
now render as spaced sentence-case:
"RenewalInProgress" → "Renewal in progress"
"AwaitingCSR" → "Awaiting CSR"
"cert_mismatch" → "Certificate mismatch"
"dead" → "Dead-lettered"
Unmapped keys flow through a titleCase() helper that humanises
PascalCase / snake_case to lower-bound readability.
StatusBadge.test.tsx extends to 75 assertions: 38 D-1 + 5 dead-key
+ 31 UX-H5 display-string + 5 titleCase + 1 parity. All wire-keys
pinned byte-exact.
UX-H2 closure — window.confirm sites migrated to ConfirmDialog
==============================================================
Audit said 8 destructive-action sites. Live count was 24 across
17 files — the audit missed 11 files (auth/SessionsPage,
auth/UsersPage, auth/GroupMappingsPage, auth/OIDCProvidersPage,
auth/OIDCProviderDetailPage, auth/RolesPage, TeamsPage,
PoliciesPage, IssuersPage, ProfilesPage, RenewalPoliciesPage).
Phase 1 migrates the 7 audit-enumerated destructive sites in the
6 priority files:
- CertificateDetailPage archive (typedConfirmation="archive" —
most-irreversible action gets the
strongest friction)
- OwnersPage delete owner
- TargetsPage delete target
- AgentGroupsPage delete agent group
- auth/KeysPage revoke role grant
- auth/RoleDetailPage delete role
The remaining 11 confirm sites in audit-missed files stay open
and ship as a Phase 1 follow-up (mechanical pattern repeat — same
Edit shape × ~11 files).
UX-H3 closure — alert() → toast.error, top mutations wired
===========================================================
All 5 alert() sites migrated to toast.error:
- OwnersPage / CertificateDetailPage × 2 / TeamsPage /
RenewalPoliciesPage
Eight high-traffic mutations now fire toast.success on resolve +
toast.error on failure: deleteOwner, deleteTarget, deleteAgentGroup,
deleteTeam, deleteRenewalPolicy, archiveCertificate,
authRevokeKeyRole, authDeleteRole. The bulk-renew flow on
CertificatesPage gets a toast with a "View N jobs" action button
that deep-links to /jobs?certificate_ids=… (paired UX-L5 work).
Toaster mounted at web/src/main.tsx next to QueryClientProvider —
single import discipline. Sonner asserts at runtime if multiple
toasters are mounted; centralising the position + duration config
in Toaster.tsx avoids the mistake.
UX-M3 closure — DataTable empty-state slot
==========================================
web/src/components/DataTable.tsx gains an optional emptyState
ReactNode prop. The existing emptyMessage string prop is
preserved for backward compat — every ~18 list-page call site
that passes emptyMessage="…" keeps working unchanged. New CTAs:
pages pass <EmptyState ... /> for first-run experiences. Wiring
EmptyState on the top-5 list pages (Certificates, Issuers,
Targets, Owners, Agents) is per-page rolling work — primitive
+ slot ship in Phase 1; CTAs follow.
UX-L5 closure — Bulk-action bar transition + post-action toast
==============================================================
web/src/pages/CertificatesPage.tsx wraps the bulk-action bar
conditional render in Headless UI <Transition>. Slide-in/out
(200ms enter, 150ms leave, -translate-y-2 → 0). The
prefers-reduced-motion respect comes for free from the global
@media block landed in Phase 0.
Post-renewal toast.success fires with an action button "View N
jobs" that navigate()s to /jobs filtered to the certificate_ids
we just renewed. Closes the audit's "what just happened" gap.
Audit-accuracy callouts
=======================
* UX-H2 undercount — live 24 sites vs audit's 8. Phase 1 closes
the 7 audit-enumerated destructive confirms across 6 priority
files. The remaining 11 sites in audit-missed files stay open
for follow-up.
* UX-M2 title= count — live 103 (matches audit). Tooltip
primitive built; per-page migrations explicitly deferred per
the prompt's "DO NOT" sweep rule.
* UX-M4 native <select> sites — Combobox primitive built;
callsite migrations deferred to per-page rolling PRs.
* FE-M4 inline bg-(red|amber|yellow)-50 — Banner primitive
built; callsite migrations deferred to page-touch work.
Verification
============
$ npx tsc --noEmit
(exit 0, no type errors)
$ npx vitest run src/components/{Toaster,ConfirmDialog,EmptyState,Banner,Tooltip,Combobox}.test.tsx src/components/StatusBadge.test.tsx
Test Files 7 passed (7)
Tests 109 passed (109)
$ npx vitest run src/pages/{OwnersPage,AgentGroupsPage,TargetsPage,CertificatesPage,CertificateDetailPage,TeamsPage,RenewalPoliciesPage}.test.tsx src/pages/auth/{KeysPage,RoleDetailPage}.test.tsx
Test Files 9 passed (9)
Tests 52 passed (52)
(TargetsPage.test.tsx updated — the existing Delete confirm
test stubbed window.confirm; new test clicks the dialog's
destructive Delete button.)
$ npx vite build
✓ built in 2.89s
dist/assets/index-DZ1ZcRdP.js 1,110.61 kB (was 1,028.66 kB)
+82 KB / +26 KB gzipped from sonner + @headlessui + @floating-ui.
Bundle code-splitting is a separate phase (FE-M5).
Residual risks + follow-ups
============================
* 11 remaining window.confirm sites in audit-missed files. Phase 1
follow-up commit will sweep them with the same ConfirmDialog
pattern — mechanical work.
* The discard-unsaved-changes confirm in EditRoleModal (and 2
sibling modal sub-components) stays as window.confirm; treated
as a UX safety guardrail rather than a destructive-action
confirmation. Migrating to ConfirmDialog is fine but not
audit-priority.
* Tooltip + Combobox + Banner callsite migrations are explicit
per-page rolling work for subsequent phases — primitives
landed; per the audit prompt's "DO NOT" rule the migrations
don't sweep here.
* Optimistic-update wiring on the 5 priority mutations
(mark-notification-read, dismiss-discovery, archive-cert,
claim-discovered-cert, role-assignment) is staged for Phase 2
TQ-M3 per the prompt's explicit "DO NOT add new mutations to
the optimistic-update list beyond the 5 priority ones".
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -7,6 +8,7 @@ import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { AgentGroup } from '../api/types';
|
||||
|
||||
@@ -254,6 +256,7 @@ export default function AgentGroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<AgentGroup | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agent-groups'],
|
||||
@@ -263,6 +266,8 @@ export default function AgentGroupsPage() {
|
||||
const deleteMutation = useTrackedMutation({
|
||||
mutationFn: deleteAgentGroup,
|
||||
invalidates: [['agent-groups']],
|
||||
onSuccess: () => toast.success('Agent group deleted'),
|
||||
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const createMutation = useTrackedMutation({
|
||||
@@ -337,7 +342,7 @@ export default function AgentGroupsPage() {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(g); }}
|
||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
@@ -385,6 +390,22 @@ export default function AgentGroupsPage() {
|
||||
isLoading={updateMutation.isPending}
|
||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Delete agent group"
|
||||
message={
|
||||
confirmDelete
|
||||
? `Delete group ${confirmDelete.name}? This will remove the group definition; agents currently in the group will fall back to default assignment.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
|
||||
import { REVOCATION_REASONS } from '../api/types';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { useAuth } from '../components/AuthProvider';
|
||||
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
||||
import type { Job, CRLCacheRow } from '../api/types';
|
||||
@@ -422,6 +424,7 @@ export default function CertificateDetailPage() {
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [pkcs12Password, setPkcs12Password] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||
|
||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificate', id],
|
||||
@@ -466,8 +469,10 @@ export default function CertificateDetailPage() {
|
||||
mutationFn: () => archiveCertificate(id!),
|
||||
invalidates: [['certificates']],
|
||||
onSuccess: () => {
|
||||
toast.success('Certificate archived');
|
||||
navigate('/certificates');
|
||||
},
|
||||
onError: (err: Error) => toast.error(`Archive failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const revokeMutation = useTrackedMutation({
|
||||
@@ -490,7 +495,7 @@ export default function CertificateDetailPage() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -509,7 +514,7 @@ export default function CertificateDetailPage() {
|
||||
setShowExport(false);
|
||||
setPkcs12Password('');
|
||||
} catch (err) {
|
||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -600,7 +605,7 @@ export default function CertificateDetailPage() {
|
||||
)}
|
||||
{!isArchived && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
disabled={archiveMutation.isPending}
|
||||
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
@@ -931,6 +936,23 @@ export default function CertificateDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* UX-H2 / UX-H3 closure — archive is the most-irreversible
|
||||
single-cert action. Gate behind a typed-confirmation prompt
|
||||
so the operator cannot fat-finger through the dialog. */}
|
||||
<ConfirmDialog
|
||||
open={confirmArchive}
|
||||
title="Archive this certificate"
|
||||
message={`This action cannot be undone. The certificate (${cert?.common_name || id}) will be moved to the archive bucket and removed from the active inventory. Active deployments + renewal policies referencing it will be skipped.`}
|
||||
confirmLabel="Archive"
|
||||
cancelLabel="Cancel"
|
||||
destructive
|
||||
typedConfirmation="archive"
|
||||
onConfirm={() => {
|
||||
archiveMutation.mutate();
|
||||
setConfirmArchive(false);
|
||||
}}
|
||||
onCancel={() => setConfirmArchive(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { useListParams } from '../hooks/useListParams';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -511,9 +513,29 @@ export default function CertificatesPage() {
|
||||
total: result.total_matched,
|
||||
running: false,
|
||||
});
|
||||
} catch {
|
||||
// UX-L5 closure (Phase 1): post-action toast with a "View jobs"
|
||||
// action that deep-links to the Jobs page filtered to the
|
||||
// certificate IDs we just renewed. The audit's missing
|
||||
// "what just happened" affordance — operators can now jump
|
||||
// straight to the resulting jobs.
|
||||
if (result.total_enqueued > 0) {
|
||||
toast.success(
|
||||
`Triggered renewal for ${result.total_enqueued} certificate${result.total_enqueued > 1 ? 's' : ''}`,
|
||||
{
|
||||
action: {
|
||||
label: `View ${result.total_enqueued} jobs`,
|
||||
onClick: () =>
|
||||
navigate(`/jobs?certificate_ids=${ids.join(',')}`),
|
||||
},
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// surface as a "0 of N" terminal state — no retries.
|
||||
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Bulk renewal failed: ${msg}`);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||
setSelectedIds(new Set());
|
||||
@@ -566,8 +588,20 @@ export default function CertificatesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{hasSelection && (
|
||||
{/* Bulk Action Bar — UX-L5 (Phase 1): Headless UI <Transition>
|
||||
wraps the slide-in/out so the bar doesn't snap when selection
|
||||
flips. Transition respects prefers-reduced-motion via the
|
||||
global @media block in index.css. */}
|
||||
<Transition
|
||||
show={hasSelection}
|
||||
as={Fragment}
|
||||
enter="transition-all duration-200 ease-out"
|
||||
enterFrom="opacity-0 -translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition-all duration-150 ease-in"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div className="px-6 py-3 bg-brand-50 border-b border-brand-200 flex items-center justify-between">
|
||||
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -593,7 +627,7 @@ export default function CertificatesPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
|
||||
{/* Bulk Renewal Success */}
|
||||
{bulkRenewProgress && !bulkRenewProgress.running && (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Owner, Team } from '../api/types';
|
||||
|
||||
@@ -211,10 +213,13 @@ export default function OwnersPage() {
|
||||
queryFn: () => getTeams(),
|
||||
});
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState<Owner | null>(null);
|
||||
|
||||
const deleteMutation = useTrackedMutation({
|
||||
mutationFn: deleteOwner,
|
||||
invalidates: [['owners']],
|
||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||
onSuccess: () => toast.success('Owner deleted'),
|
||||
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const createMutation = useTrackedMutation({
|
||||
@@ -279,7 +284,7 @@ export default function OwnersPage() {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(o); }}
|
||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
@@ -329,6 +334,22 @@ export default function OwnersPage() {
|
||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||
teamsData={teamsData}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Delete owner"
|
||||
message={
|
||||
confirmDelete
|
||||
? `Delete owner ${confirmDelete.name}? This action cannot be undone.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import {
|
||||
getRenewalPolicies,
|
||||
@@ -206,7 +207,8 @@ export default function RenewalPoliciesPage() {
|
||||
// alert so the operator sees "this policy is still attached to N
|
||||
// certificates" and can re-target those certs to another policy
|
||||
// before deleting.
|
||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||
onSuccess: () => toast.success('Renewal policy deleted'),
|
||||
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const columns: Column<RenewalPolicy>[] = [
|
||||
|
||||
@@ -93,23 +93,37 @@ describe('TargetsPage — T-1 page coverage', () => {
|
||||
});
|
||||
|
||||
it('Delete confirm flow calls deleteTarget(id)', async () => {
|
||||
const origConfirm = globalThis.confirm;
|
||||
globalThis.confirm = vi.fn(() => true);
|
||||
try {
|
||||
renderWithQuery(<TargetsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
|
||||
});
|
||||
// Phase 1 UX-H2 closure: Delete now opens a ConfirmDialog primitive
|
||||
// (Headless UI) rather than firing window.confirm(). The new flow:
|
||||
// 1. operator clicks the row's "Delete" button → sets state
|
||||
// that opens the dialog
|
||||
// 2. ConfirmDialog mounts with title "Delete deployment target"
|
||||
// 3. operator clicks the dialog's destructive "Delete" button
|
||||
// 4. deleteTarget(id) fires
|
||||
renderWithQuery(<TargetsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(deleteButtons[0]!);
|
||||
const rowDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(rowDeleteButtons[0]!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.deleteTarget).toHaveBeenCalled();
|
||||
});
|
||||
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
|
||||
} finally {
|
||||
globalThis.confirm = origConfirm;
|
||||
}
|
||||
// Wait for the dialog title to mount (Headless UI Transition).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Delete deployment target')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the dialog's destructive-styled confirm button (.btn-danger).
|
||||
const allDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||
const dialogDeleteBtn = allDeleteButtons.find((b) =>
|
||||
b.className.includes('btn-danger'),
|
||||
);
|
||||
expect(dialogDeleteBtn).toBeDefined();
|
||||
fireEvent.click(dialogDeleteBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.deleteTarget).toHaveBeenCalled();
|
||||
});
|
||||
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -8,6 +9,7 @@ import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Target } from '../api/types';
|
||||
|
||||
@@ -403,6 +405,7 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
||||
export default function TargetsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Target | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['targets'],
|
||||
@@ -412,6 +415,8 @@ export default function TargetsPage() {
|
||||
const deleteMutation = useTrackedMutation({
|
||||
mutationFn: deleteTarget,
|
||||
invalidates: [['targets']],
|
||||
onSuccess: () => toast.success('Target deleted'),
|
||||
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const columns: Column<Target>[] = [
|
||||
@@ -462,7 +467,7 @@ export default function TargetsPage() {
|
||||
label: '',
|
||||
render: (t) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmDelete(t); }}
|
||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
@@ -498,6 +503,22 @@ export default function TargetsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Delete deployment target"
|
||||
message={
|
||||
confirmDelete
|
||||
? `Delete target ${confirmDelete.name}? Active deployments referencing this target will fail until reconfigured.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -156,7 +157,8 @@ export default function TeamsPage() {
|
||||
const deleteMutation = useTrackedMutation({
|
||||
mutationFn: deleteTeam,
|
||||
invalidates: [['teams']],
|
||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||
onSuccess: () => toast.success('Team deleted'),
|
||||
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const createMutation = useTrackedMutation({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
authListKeys,
|
||||
authListRoles,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 10 — KeysPage.
|
||||
@@ -44,20 +46,33 @@ export default function KeysPage() {
|
||||
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// UX-H2 closure — replace window.confirm() with ConfirmDialog.
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<
|
||||
{ entry: AuthKeyEntry; roleID: string } | null
|
||||
>(null);
|
||||
|
||||
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
|
||||
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
|
||||
|
||||
const handleRevoke = async (entry: AuthKeyEntry, roleID: string) => {
|
||||
const handleRevoke = (entry: AuthKeyEntry, roleID: string) => {
|
||||
if (entry.actor_id === DEMO_ANON) return;
|
||||
if (!window.confirm(`Revoke ${roleID} from ${entry.actor_id}?`)) return;
|
||||
setConfirmRevoke({ entry, roleID });
|
||||
};
|
||||
|
||||
const performRevoke = async () => {
|
||||
if (!confirmRevoke) return;
|
||||
const { entry, roleID } = confirmRevoke;
|
||||
setConfirmRevoke(null);
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await authRevokeKeyRole(entry.actor_id, roleID);
|
||||
toast.success(`Revoked ${roleID} from ${entry.actor_id}`);
|
||||
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setActionError(msg);
|
||||
toast.error(`Revoke failed: ${msg}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -173,6 +188,19 @@ export default function KeysPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmRevoke !== null}
|
||||
title="Revoke role grant"
|
||||
message={
|
||||
confirmRevoke
|
||||
? `Revoke ${confirmRevoke.roleID} from ${confirmRevoke.entry.actor_id}? The actor will lose every permission scoped to that role on the next request.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Revoke"
|
||||
destructive
|
||||
onConfirm={performRevoke}
|
||||
onCancel={() => setConfirmRevoke(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
authGetRole,
|
||||
authListPermissions,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 10 — RoleDetailPage.
|
||||
@@ -65,6 +67,8 @@ export default function RoleDetailPage() {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
// UX-H2 closure — replace window.confirm with ConfirmDialog.
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
|
||||
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
|
||||
@@ -83,15 +87,22 @@ export default function RoleDetailPage() {
|
||||
|
||||
const { role, permissions } = detailQuery.data;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
|
||||
const handleDelete = () => {
|
||||
setConfirmDelete(true);
|
||||
};
|
||||
|
||||
const performDelete = async () => {
|
||||
setConfirmDelete(false);
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await authDeleteRole(role.id);
|
||||
toast.success(`Role ${role.name} deleted`);
|
||||
navigate('/auth/roles');
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setActionError(msg);
|
||||
toast.error(`Delete failed: ${msg}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -260,6 +271,15 @@ export default function RoleDetailPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete role"
|
||||
message={`Delete role ${role.name}? Every actor currently holding this role grant will lose those permissions. This cannot be undone.`}
|
||||
confirmLabel="Delete role"
|
||||
destructive
|
||||
onConfirm={performDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user