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:
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user