add row-level actions: disable user, archive licenses/certs

Users list gets a disable/enable button per row (admin-only).
Licenses and certs lists get archive/unarchive buttons per row
(VELA write/admin only). All use confirmation dialogs and existing
backend endpoints with proper permission gating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 14:23:21 -07:00
parent d15bd88fee
commit a86e816d96
7 changed files with 281 additions and 14 deletions

View File

@@ -213,10 +213,10 @@ Users associated table
## Round 6
[x] project verification setting - default 90 days
[] Dashboard > add Unverified count (and auto filters to verified status)
[] Dashboard > cards > show total of expiring
[] Users list- quick disable user via row button
[] Licenses - row level archive
[x] Dashboard > add Unverified count (and auto filters to verified status)
[x] Dashboard > cards > show total of expiring
[x] Users list- quick disable user via row button
[x] Licenses - row level archive (and certs)
[] Total number of non-archived Licenses and certs
[] Users total count
[] Downtime tracker > required fields: App, start, enclave, scope, planned

View File

@@ -1,10 +1,17 @@
import type { ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow, parseISO } from "date-fns";
import { Archive, ArchiveRestore } from "lucide-react";
import type { Cert } from "@/hooks/use-certs";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function getCertsColumns(): ColumnDef<Cert, unknown>[] {
interface CertsColumnsOptions {
onArchiveToggle?: (cert: Cert) => void;
}
export function getCertsColumns(options?: CertsColumnsOptions): ColumnDef<Cert, unknown>[] {
return [
{
accessorKey: "name",
@@ -83,5 +90,43 @@ export function getCertsColumns(): ColumnDef<Cert, unknown>[] {
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
filterFn: "arrIncludes",
},
...(options?.onArchiveToggle
? [
{
id: "actions",
header: "",
cell: ({ row }: { row: { original: Cert } }) => {
const cert = row.original;
return (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => options.onArchiveToggle!(cert)}
>
{cert.archived ? (
<ArchiveRestore className="size-4 text-muted-foreground" />
) : (
<Archive className="size-4 text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>
{cert.archived ? "Unarchive certificate" : "Archive certificate"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
},
} satisfies ColumnDef<Cert, unknown>,
]
: []),
];
}

View File

@@ -1,10 +1,17 @@
import type { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow, parseISO } from "date-fns";
import { Archive, ArchiveRestore } from "lucide-react";
import type { License } from "@/hooks/use-licenses";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function getLicensesColumns(): ColumnDef<License, unknown>[] {
interface LicensesColumnsOptions {
onArchiveToggle?: (license: License) => void;
}
export function getLicensesColumns(options?: LicensesColumnsOptions): ColumnDef<License, unknown>[] {
return [
{
accessorKey: "name",
@@ -72,5 +79,43 @@ export function getLicensesColumns(): ColumnDef<License, unknown>[] {
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
filterFn: "arrIncludes",
},
...(options?.onArchiveToggle
? [
{
id: "actions",
header: "",
cell: ({ row }: { row: { original: License } }) => {
const lic = row.original;
return (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => options.onArchiveToggle!(lic)}
>
{lic.archived ? (
<ArchiveRestore className="size-4 text-muted-foreground" />
) : (
<Archive className="size-4 text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>
{lic.archived ? "Unarchive license" : "Archive license"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
},
} satisfies ColumnDef<License, unknown>,
]
: []),
];
}

View File

@@ -1,9 +1,16 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Ban, UserCheck } from "lucide-react";
import type { User } from "@/hooks/use-users";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export function getUsersColumns(): ColumnDef<User, unknown>[] {
interface UsersColumnsOptions {
onToggleEnabled?: (user: User) => void;
}
export function getUsersColumns(options?: UsersColumnsOptions): ColumnDef<User, unknown>[] {
return [
{
accessorKey: "name",
@@ -91,5 +98,43 @@ export function getUsersColumns(): ColumnDef<User, unknown>[] {
},
filterFn: "arrIncludes",
},
...(options?.onToggleEnabled
? [
{
id: "actions",
header: "",
cell: ({ row }: { row: { original: User } }) => {
const user = row.original;
return (
<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => options.onToggleEnabled!(user)}
>
{user.enabled ? (
<Ban className="size-4 text-muted-foreground" />
) : (
<UserCheck className="size-4 text-muted-foreground" />
)}
</Button>
}
/>
<TooltipContent>
{user.enabled ? "Disable user" : "Enable user"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
},
} satisfies ColumnDef<User, unknown>,
]
: []),
];
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
@@ -24,6 +24,7 @@ import {
exportCert,
type Cert,
} from "@/hooks/use-certs";
import { useAuthContext } from "@/contexts/auth-context";
const STATUS_OPTIONS = [
{ label: "Valid", value: "valid" },
@@ -34,6 +35,8 @@ const STATUS_OPTIONS = [
];
export default function CertsListPage() {
const { user } = useAuthContext();
const canArchive = user?.is_admin || (user?.permissions?.global_role && ["write", "admin"].includes(user.permissions.global_role));
const { data: certs, isLoading } = useCerts();
const createCert = useCreateCert();
const importCert = useImportCert();
@@ -45,6 +48,8 @@ export default function CertsListPage() {
const [editingCert, setEditingCert] = useState<Cert | null>(null);
const [deletingCert, setDeletingCert] = useState<Cert | null>(null);
const [exportingCert, setExportingCert] = useState<Cert | null>(null);
const [archiveTarget, setArchiveTarget] = useState<Cert | null>(null);
const [archivePending, setArchivePending] = useState(false);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const { data: fullEditingCert } = useCert(editingCert?.id ?? undefined);
@@ -115,7 +120,11 @@ export default function CertsListPage() {
const statusFilterValue = (columnFilters.find((f) => f.id === "status")?.value as string[]) ?? [];
const issuerFilterValue = (columnFilters.find((f) => f.id === "issuer")?.value as string[]) ?? [];
const columns = useMemo(() => getCertsColumns(), []);
const onArchiveToggle = useCallback((cert: Cert) => setArchiveTarget(cert), []);
const columns = useMemo(
() => getCertsColumns(canArchive ? { onArchiveToggle } : undefined),
[canArchive, onArchiveToggle],
);
function handleCreate(data: Parameters<typeof createCert.mutate>[0]) {
createCert.mutate(data, {
@@ -320,6 +329,37 @@ export default function CertsListPage() {
onConfirm={handleDelete}
isLoading={deleteCert.isPending}
/>
{/* Row archive confirm */}
{archiveTarget && (
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => { if (!open) setArchiveTarget(null); }}
title={archiveTarget.archived ? "Unarchive Certificate" : "Archive Certificate"}
description={
archiveTarget.archived
? `Unarchive "${archiveTarget.name}"? It will reappear in active views.`
: `Archive "${archiveTarget.name}"? It will be hidden from active views.`
}
confirmLabel={archiveTarget.archived ? "Unarchive" : "Archive"}
variant={archiveTarget.archived ? "success" : "destructive"}
isLoading={archivePending}
onConfirm={async () => {
setArchivePending(true);
try {
await api.put(`/certs/${archiveTarget.id}`, { archived: !archiveTarget.archived });
queryClient.invalidateQueries({ queryKey: ["certs"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success(archiveTarget.archived ? "Certificate unarchived" : "Certificate archived");
setArchiveTarget(null);
} catch {
toast.error("Failed to update certificate");
} finally {
setArchivePending(false);
}
}}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
@@ -27,6 +27,7 @@ import {
useDeleteLicense,
type License,
} from "@/hooks/use-licenses";
import { useAuthContext } from "@/contexts/auth-context";
const STATUS_OPTIONS = [
{ label: "Pending", value: "pending" },
@@ -47,6 +48,8 @@ const STATUS_RULES = [
] as const;
export default function LicensesListPage() {
const { user } = useAuthContext();
const canArchive = user?.is_admin || (user?.permissions?.global_role && ["write", "admin"].includes(user.permissions.global_role));
const { data: licenses, isLoading } = useLicenses();
const createLicense = useCreateLicense();
const queryClient = useQueryClient();
@@ -55,6 +58,8 @@ export default function LicensesListPage() {
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingLicense, setEditingLicense] = useState<License | null>(null);
const [deletingLicense, setDeletingLicense] = useState<License | null>(null);
const [archiveTarget, setArchiveTarget] = useState<License | null>(null);
const [archivePending, setArchivePending] = useState(false);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const { data: fullEditingLicense } = useLicense(editingLicense?.id ?? undefined);
@@ -102,7 +107,11 @@ export default function LicensesListPage() {
const statusFilterValue = (columnFilters.find((f) => f.id === "status")?.value as string[]) ?? [];
const columns = useMemo(() => getLicensesColumns(), []);
const onArchiveToggle = useCallback((lic: License) => setArchiveTarget(lic), []);
const columns = useMemo(
() => getLicensesColumns(canArchive ? { onArchiveToggle } : undefined),
[canArchive, onArchiveToggle],
);
function handleCreate(data: FormData, pendingFiles?: File[]) {
createLicense.mutate(data, {
@@ -306,6 +315,41 @@ export default function LicensesListPage() {
onConfirm={handleDelete}
isLoading={deleteLicense.isPending}
/>
{/* Row archive confirm */}
{archiveTarget && (
<ConfirmDialog
open={!!archiveTarget}
onOpenChange={(open) => { if (!open) setArchiveTarget(null); }}
title={archiveTarget.archived ? "Unarchive License" : "Archive License"}
description={
archiveTarget.archived
? `Unarchive "${archiveTarget.name}"? It will reappear in active views.`
: `Archive "${archiveTarget.name}"? It will be hidden from active views.`
}
confirmLabel={archiveTarget.archived ? "Unarchive" : "Archive"}
variant={archiveTarget.archived ? "success" : "destructive"}
isLoading={archivePending}
onConfirm={async () => {
setArchivePending(true);
try {
const data = new FormData();
data.append("archived", String(!archiveTarget.archived));
await api.put(`/licenses/${archiveTarget.id}`, data, {
headers: { "Content-Type": "multipart/form-data" },
});
queryClient.invalidateQueries({ queryKey: ["licenses"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success(archiveTarget.archived ? "License unarchived" : "License archived");
setArchiveTarget(null);
} catch {
toast.error("Failed to update license");
} finally {
setArchivePending(false);
}
}}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Plus } from "lucide-react";
@@ -6,9 +6,13 @@ import type { ColumnFiltersState } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/shared/data-table";
import { DataTableFacetedFilter } from "@/components/shared/data-table-faceted-filter";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { getUsersColumns } from "@/components/users/users-columns";
import { UserForm } from "@/components/users/user-form";
import { useUsers, useCreateUser } from "@/hooks/use-users";
import { useQueryClient } from "@tanstack/react-query";
import { useUsers, useCreateUser, type User } from "@/hooks/use-users";
import { useAuthContext } from "@/contexts/auth-context";
import api from "@/lib/api";
const STATUS_OPTIONS = [
{ label: "Sponsored", value: "sponsored" },
@@ -17,6 +21,9 @@ const STATUS_OPTIONS = [
];
export default function UsersListPage() {
const { user: authUser } = useAuthContext();
const isAdmin = authUser?.is_admin ?? false;
const queryClient = useQueryClient();
const { data: users, isLoading } = useUsers();
const createUser = useCreateUser();
const navigate = useNavigate();
@@ -24,6 +31,8 @@ export default function UsersListPage() {
const [showCreateForm, setShowCreateForm] = useState(false);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [toggleTarget, setToggleTarget] = useState<User | null>(null);
const [togglePending, setTogglePending] = useState(false);
// Read URL params on mount for filters
useEffect(() => {
@@ -52,7 +61,11 @@ export default function UsersListPage() {
const statusFilterValue = (columnFilters.find((f) => f.id === "status")?.value as string[]) ?? [];
const columns = useMemo(() => getUsersColumns(), []);
const onToggleEnabled = useCallback((user: User) => setToggleTarget(user), []);
const columns = useMemo(
() => getUsersColumns(isAdmin ? { onToggleEnabled } : undefined),
[isAdmin, onToggleEnabled],
);
function handleCreate(data: { first_name: string; last_name: string; email: string }) {
createUser.mutate(data, {
@@ -124,6 +137,41 @@ export default function UsersListPage() {
onSubmit={handleCreate}
isLoading={createUser.isPending}
/>
{toggleTarget && (
<ConfirmDialog
open={!!toggleTarget}
onOpenChange={(open) => { if (!open) setToggleTarget(null); }}
title={toggleTarget.enabled ? "Disable User" : "Enable User"}
description={
toggleTarget.enabled
? `This will disable ${toggleTarget.name} in Keycloak. They will no longer be able to log in.`
: `This will re-enable ${toggleTarget.name} in Keycloak.`
}
confirmLabel={toggleTarget.enabled ? "Disable" : "Enable"}
variant={toggleTarget.enabled ? "destructive" : "success"}
isLoading={togglePending}
onConfirm={async () => {
setTogglePending(true);
try {
await api.put(`/users/${toggleTarget.id}`, {
enabled: !toggleTarget.enabled,
});
queryClient.invalidateQueries({ queryKey: ["users"] });
toast.success(
toggleTarget.enabled
? `${toggleTarget.name} has been disabled`
: `${toggleTarget.name} has been enabled`,
);
setToggleTarget(null);
} catch {
toast.error("Failed to update user");
} finally {
setTogglePending(false);
}
}}
/>
)}
</div>
);
}