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:
shankar0123
2026-05-14 14:25:41 +00:00
parent 93e00f6a5e
commit e37403edf1
28 changed files with 1789 additions and 47 deletions
+22 -1
View File
@@ -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)}
/>
</>
);
}
+25 -3
View File
@@ -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)}
/>
</>
);
}
+39 -5
View File
@@ -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 && (
+23 -2
View File
@@ -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)}
/>
</>
);
}
+3 -1
View File
@@ -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>[] = [
+30 -16
View File
@@ -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');
});
});
+22 -1
View File
@@ -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)}
/>
</>
);
}
+3 -1
View File
@@ -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({
+31 -3
View File
@@ -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>
);
}
+23 -3
View File
@@ -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>
);
}