forked from baron/baron-sso
감사로그 화면 연동과 상태변경 로그 분리 및 CSV/UI 개선
This commit is contained in:
@@ -1,141 +1,415 @@
|
||||
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
const auditFilters = [
|
||||
"Actor role = admin",
|
||||
"Action = client.rotate_secret",
|
||||
"Tenant = selected header",
|
||||
];
|
||||
type AuditDetails = {
|
||||
request_id?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
tenant_id?: string;
|
||||
action?: string;
|
||||
target_id?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const auditRows = [
|
||||
{
|
||||
action: "client.create",
|
||||
tenant: "TENANT-12",
|
||||
actor: "ops.jane@baron",
|
||||
result: "ok",
|
||||
ts: "2026-01-26 15:21 KST",
|
||||
},
|
||||
{
|
||||
action: "client.rotate_secret",
|
||||
tenant: "TENANT-12",
|
||||
actor: "ops.jane@baron",
|
||||
result: "ok",
|
||||
ts: "2026-01-26 15:22 KST",
|
||||
},
|
||||
{
|
||||
action: "audit.export",
|
||||
tenant: "TENANT-07",
|
||||
actor: "auditor.lee@baron",
|
||||
result: "rate_limited",
|
||||
ts: "2026-01-26 15:30 KST",
|
||||
},
|
||||
];
|
||||
function parseDetails(details?: string): AuditDetails {
|
||||
if (!details) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(details);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return parsed as AuditDetails;
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
function toCsv(logs: DevAuditLog[]) {
|
||||
const header = [
|
||||
"timestamp",
|
||||
"user_id",
|
||||
"status",
|
||||
"event_type",
|
||||
"action",
|
||||
"target_id",
|
||||
"tenant_id",
|
||||
"request_id",
|
||||
];
|
||||
const rows = logs.map((logItem) => {
|
||||
const details = parseDetails(logItem.details);
|
||||
return [
|
||||
logItem.timestamp,
|
||||
logItem.user_id || "",
|
||||
logItem.status,
|
||||
logItem.event_type,
|
||||
details.action || "",
|
||||
details.target_id || "",
|
||||
details.tenant_id || "",
|
||||
details.request_id || "",
|
||||
];
|
||||
});
|
||||
return [header, ...rows]
|
||||
.map((line) =>
|
||||
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function downloadCsv(content: string, filename: string) {
|
||||
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchClientId, setSearchClientId] = React.useState("");
|
||||
const [searchAction, setSearchAction] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchDevAuditLogs(50, pageParam, {
|
||||
client_id: searchClientId.trim() || undefined,
|
||||
action: searchAction.trim() || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
|
||||
const logs =
|
||||
query.data?.pages.flatMap((page) =>
|
||||
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
||||
) ?? [];
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
const csv = toCsv(logs);
|
||||
const stamp = new Date().toISOString().replaceAll(":", "-");
|
||||
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
||||
};
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
const errMsg =
|
||||
(query.error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(query.error as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
||||
error: errMsg,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Audit stream
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Observe admin actions per tenant
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
ClickHouse-backed feed. Filter by tenant, actor, action, and
|
||||
rate-limit status. Enforce admin-only access under /admin.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
|
||||
>
|
||||
<Filter size={14} />
|
||||
Saved filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t("ui.dev.audit.registry.title", "Audit registry")}
|
||||
</p>
|
||||
<CardTitle className="text-3xl font-black tracking-tight">
|
||||
{t("ui.dev.audit.title", "Audit Logs")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.audit.subtitle",
|
||||
"Shows DevFront activity history within current tenant/app scope.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="muted">
|
||||
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
|
||||
<Search size={14} />
|
||||
<span className="text-sm">
|
||||
Try: tenant:TENANT-12 action:client.*
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{auditFilters.map((filter) => (
|
||||
<span
|
||||
key={filter}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
{filter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 divide-y divide-[var(--color-border)]">
|
||||
{auditRows.map((row) => (
|
||||
<div
|
||||
key={`${row.action}-${row.ts}`}
|
||||
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
|
||||
>
|
||||
<div className="font-semibold">{row.action}</div>
|
||||
<div className="text-[var(--color-muted)]">{row.tenant}</div>
|
||||
<div className="text-[var(--color-muted)]">{row.actor}</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs ${
|
||||
row.result === "ok"
|
||||
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
|
||||
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
|
||||
}`}
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[190px]">
|
||||
{t("ui.dev.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.actor", "Actor")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
{t("ui.dev.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[260px]">
|
||||
{t("ui.dev.audit.table.target", "Target")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{t("ui.dev.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{row.result}
|
||||
</span>
|
||||
<span className="text-[var(--color-muted)]">{row.ts}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{t("msg.dev.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs.map((row, index) => {
|
||||
const details = parseDetails(row.details);
|
||||
const actionLabel = details.action || row.event_type;
|
||||
const targetValue = details.target_id || "-";
|
||||
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDateTime(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{row.user_id || "-"}</span>
|
||||
{row.user_id ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(row.user_id)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{actionLabel}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetValue}</span>
|
||||
{targetValue !== "-" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground"
|
||||
onClick={() => handleCopy(targetValue)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
row.status === "success" ? "success" : "warning"
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded ? (
|
||||
<TableRow className="bg-card/20">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
Request ID: {formatValue(details.request_id)}
|
||||
</div>
|
||||
<div>Method: {formatValue(details.method)}</div>
|
||||
<div>Path: {formatValue(details.path)}</div>
|
||||
<div>
|
||||
Tenant: {formatValue(details.tenant_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 break-all">
|
||||
<div>Before: {formatValue(details.before)}</div>
|
||||
<div>After: {formatValue(details.after)}</div>
|
||||
<div>Error: {formatValue(details.error)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Guard rails
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Enforce Tenant Admin middleware and admin session TTL before
|
||||
surfacing any audit feed. Super Admin role can bypass tenant
|
||||
filter when needed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Export rules
|
||||
</p>
|
||||
<h3 className="mt-1 text-lg font-semibold">
|
||||
Rate-limit sensitive exports
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Keep export endpoints behind admin-only routes with ClickHouse
|
||||
query limits. Log download attempts with IP, role, and tenant
|
||||
scope.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{query.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
>
|
||||
{query.isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.dev.audit.load_more", "Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Filter,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
@@ -275,6 +276,7 @@ function ClientConsentsPage() {
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
updateClient,
|
||||
updateClientStatus,
|
||||
} from "../../lib/devApi";
|
||||
import type {
|
||||
ClientStatus,
|
||||
@@ -63,6 +64,7 @@ function ClientGeneralPage() {
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [clientType, setClientType] = useState<ClientType>("private");
|
||||
const [status, setStatus] = useState<ClientStatus>("active");
|
||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||
const [redirectUris, setRedirectUris] = useState("");
|
||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||
{
|
||||
@@ -91,6 +93,7 @@ function ClientGeneralPage() {
|
||||
setName(client.name || client.id);
|
||||
setClientType(client.type);
|
||||
setStatus(client.status);
|
||||
setInitialStatus(client.status);
|
||||
|
||||
const metadata = client.metadata ?? {};
|
||||
if (typeof metadata.description === "string")
|
||||
@@ -158,7 +161,6 @@ function ClientGeneralPage() {
|
||||
const payload: ClientUpsertRequest = {
|
||||
name,
|
||||
type: clientType,
|
||||
status,
|
||||
scopes: scopeNames,
|
||||
metadata: {
|
||||
description,
|
||||
@@ -169,6 +171,7 @@ function ClientGeneralPage() {
|
||||
|
||||
// 생성 시에는 Redirect URIs를 포함해서 전송
|
||||
if (isCreate) {
|
||||
payload.status = status;
|
||||
payload.redirectUris = redirectUris
|
||||
.split(",")
|
||||
.map((uri) => uri.trim())
|
||||
@@ -176,11 +179,19 @@ function ClientGeneralPage() {
|
||||
return createClient(payload);
|
||||
}
|
||||
|
||||
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음)
|
||||
return updateClient(clientId as string, payload);
|
||||
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하고,
|
||||
// status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다.
|
||||
const updated = await updateClient(clientId as string, payload);
|
||||
if (status !== initialStatus) {
|
||||
await updateClientStatus(clientId as string, status);
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
if (status !== initialStatus) {
|
||||
setInitialStatus(status);
|
||||
}
|
||||
if (result?.client?.id) {
|
||||
navigate(`/clients/${result.client.id}/settings`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user