@modern-admin/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
// Admin Settings hub. Reachable from the user/profile dropdown menu and
|
|
2
|
+
// rendered by the router for `/settings/<section>`. Currently three
|
|
3
|
+
// sections: `api-keys`, `webhooks`, `ai-assistant`; the layout is built so
|
|
4
|
+
// adding more sections is just a new entry in `SECTIONS` + a switch case.
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
7
|
+
import { Badge, Button, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, InfoTooltip, Input, JsonEditor, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn, } from '@modern-admin/ui';
|
|
8
|
+
import { AlertTriangle, Bot, Check, Copy, Edit, Eye, EyeOff, KeyRound, Plus, Settings as SettingsIcon, Trash2, X, } from 'lucide-react';
|
|
9
|
+
import { useAdminClient } from '../provider.js';
|
|
10
|
+
import { useFeatures, useResources } from '../hooks.js';
|
|
11
|
+
import { useI18n } from '../i18n.js';
|
|
12
|
+
import { Link, useNavigate } from '../router.js';
|
|
13
|
+
import { useNotify } from '../notify.js';
|
|
14
|
+
import { useDialogs } from '../dialogs.js';
|
|
15
|
+
import { getSettingsSectionExtensions } from '../extension-registry.js';
|
|
16
|
+
import { AiAssistantSettingsSection } from './ai-assistant-settings-section.js';
|
|
17
|
+
import { SettingsCard, SettingsListState, SettingsTableScroll } from './settings-shared.js';
|
|
18
|
+
const KEY_LIST = ['modern-admin', 'api-keys'];
|
|
19
|
+
const KEY_WEBHOOKS = ['modern-admin', 'webhooks'];
|
|
20
|
+
const BUILT_IN_SECTIONS = [
|
|
21
|
+
{ key: 'api-keys', labelKey: 'settings:apiKeys.title', icon: KeyRound },
|
|
22
|
+
{ key: 'webhooks', labelKey: 'settings:webhooks.title', icon: SettingsIcon },
|
|
23
|
+
{ key: 'ai-assistant', labelKey: 'aiAssistant:title', icon: Bot },
|
|
24
|
+
];
|
|
25
|
+
export function SettingsPage({ section }) {
|
|
26
|
+
const { t } = useI18n();
|
|
27
|
+
const navigate = useNavigate();
|
|
28
|
+
const features = useFeatures();
|
|
29
|
+
// Only surface built-in sections whose backend subsystem is wired.
|
|
30
|
+
// Extension sections are always shown when registered.
|
|
31
|
+
const SECTIONS = React.useMemo(() => [
|
|
32
|
+
...BUILT_IN_SECTIONS.filter((s) => s.key === 'api-keys' ? features.apiKeys
|
|
33
|
+
: s.key === 'webhooks' ? features.webhooks
|
|
34
|
+
: s.key === 'ai-assistant' ? features.aiAssistant
|
|
35
|
+
: true),
|
|
36
|
+
...getSettingsSectionExtensions().map((ext) => ({
|
|
37
|
+
key: ext.key,
|
|
38
|
+
labelKey: ext.labelKey,
|
|
39
|
+
icon: ext.icon,
|
|
40
|
+
isExtension: true,
|
|
41
|
+
})),
|
|
42
|
+
], [features.apiKeys, features.webhooks, features.aiAssistant]);
|
|
43
|
+
// Resolve the requested section. If the URL is bogus or the section is
|
|
44
|
+
// disabled, fall back to the first enabled section.
|
|
45
|
+
const builtInKeys = ['api-keys', 'webhooks', 'ai-assistant'];
|
|
46
|
+
const isBuiltIn = (k) => builtInKeys.includes(k);
|
|
47
|
+
const requested = section ?? null;
|
|
48
|
+
const active = requested && SECTIONS.some((s) => s.key === requested)
|
|
49
|
+
? requested
|
|
50
|
+
: (SECTIONS[0]?.key ?? null);
|
|
51
|
+
if (active === null) {
|
|
52
|
+
// Defensive: the user menu hides Settings entirely when no section is
|
|
53
|
+
// available, so users normally won't land here. Direct navigation to
|
|
54
|
+
// /settings without any configured section ends up showing this.
|
|
55
|
+
return (<div className="rounded-md border border-dashed bg-card p-8 text-center text-sm text-muted-foreground">
|
|
56
|
+
{t('settings:noSectionsConfigured')}
|
|
57
|
+
</div>);
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
// `minmax(0,1fr)` (not bare `1fr`) lets the content column shrink below
|
|
61
|
+
// its intrinsic min-width — otherwise wide tables push the whole grid
|
|
62
|
+
// past the viewport at ~`lg` widths (~1000–1100px).
|
|
63
|
+
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[14rem_minmax(0,1fr)]">
|
|
64
|
+
{/* Mobile: dropdown selector (handles many sections gracefully) */}
|
|
65
|
+
<div className="lg:hidden">
|
|
66
|
+
<Select value={active ?? ''} onValueChange={(v) => navigate({ name: 'settings', section: v })}>
|
|
67
|
+
<SelectTrigger className="w-full">
|
|
68
|
+
<SelectValue />
|
|
69
|
+
</SelectTrigger>
|
|
70
|
+
<SelectContent>
|
|
71
|
+
{SECTIONS.map(({ key, labelKey, icon: Icon }) => (<SelectItem key={key} value={key}>
|
|
72
|
+
<span className="flex items-center gap-2">
|
|
73
|
+
<Icon className="size-4"/>
|
|
74
|
+
<span>{t(labelKey)}</span>
|
|
75
|
+
</span>
|
|
76
|
+
</SelectItem>))}
|
|
77
|
+
</SelectContent>
|
|
78
|
+
</Select>
|
|
79
|
+
</div>
|
|
80
|
+
{/* Desktop: sidebar nav */}
|
|
81
|
+
<aside className="hidden lg:block">
|
|
82
|
+
<nav className="flex flex-col gap-1">
|
|
83
|
+
{SECTIONS.map(({ key, labelKey, icon: Icon }) => (<Link key={key} to={{ name: 'settings', section: key }} className={cn('flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent', active === key && 'bg-accent font-medium')}>
|
|
84
|
+
<Icon className="size-4"/>
|
|
85
|
+
<span>{t(labelKey)}</span>
|
|
86
|
+
</Link>))}
|
|
87
|
+
</nav>
|
|
88
|
+
</aside>
|
|
89
|
+
<section className="min-w-0">
|
|
90
|
+
{active && isBuiltIn(active) && active === 'api-keys' && <ApiKeysSection />}
|
|
91
|
+
{active && isBuiltIn(active) && active === 'webhooks' && <WebhooksSection />}
|
|
92
|
+
{active && isBuiltIn(active) && active === 'ai-assistant' && <AiAssistantSettingsSection />}
|
|
93
|
+
{active && !isBuiltIn(active) && (() => {
|
|
94
|
+
const extSection = getSettingsSectionExtensions().find((e) => e.key === active);
|
|
95
|
+
return extSection ? <extSection.component /> : null;
|
|
96
|
+
})()}
|
|
97
|
+
</section>
|
|
98
|
+
</div>);
|
|
99
|
+
}
|
|
100
|
+
// ─── API Keys section ─────────────────────────────────────────────────────────
|
|
101
|
+
function ApiKeysSection() {
|
|
102
|
+
const { t } = useI18n();
|
|
103
|
+
const client = useAdminClient();
|
|
104
|
+
const qc = useQueryClient();
|
|
105
|
+
const notify = useNotify();
|
|
106
|
+
const dialogs = useDialogs();
|
|
107
|
+
const resources = useResources();
|
|
108
|
+
const [editorOpen, setEditorOpen] = React.useState(false);
|
|
109
|
+
const [editing, setEditing] = React.useState(null);
|
|
110
|
+
const [createdSecret, setCreatedSecret] = React.useState(null);
|
|
111
|
+
const list = useQuery({
|
|
112
|
+
queryKey: KEY_LIST,
|
|
113
|
+
queryFn: () => client.listApiKeys(),
|
|
114
|
+
});
|
|
115
|
+
const deleteMut = useMutation({
|
|
116
|
+
mutationFn: (id) => client.deleteApiKey(id),
|
|
117
|
+
onSuccess: () => {
|
|
118
|
+
qc.invalidateQueries({ queryKey: KEY_LIST });
|
|
119
|
+
notify.success({ key: 'settings:apiKeys.notice.revoked' });
|
|
120
|
+
},
|
|
121
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
122
|
+
});
|
|
123
|
+
const toggleEnabledMut = useMutation({
|
|
124
|
+
mutationFn: (vars) => client.updateApiKey(vars.id, { enabled: vars.enabled }),
|
|
125
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_LIST }),
|
|
126
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
127
|
+
});
|
|
128
|
+
const onCreate = () => {
|
|
129
|
+
setEditing(null);
|
|
130
|
+
setEditorOpen(true);
|
|
131
|
+
};
|
|
132
|
+
const onEdit = (key) => {
|
|
133
|
+
setEditing(key);
|
|
134
|
+
setEditorOpen(true);
|
|
135
|
+
};
|
|
136
|
+
const onRevoke = async (key) => {
|
|
137
|
+
const ok = await dialogs.confirm({
|
|
138
|
+
title: t('settings:apiKeys.confirmRevoke.title'),
|
|
139
|
+
description: t('settings:apiKeys.confirmRevoke.description', { name: key.name ?? key.id }),
|
|
140
|
+
confirmLabel: t('settings:apiKeys.actions.revoke'),
|
|
141
|
+
destructive: true,
|
|
142
|
+
});
|
|
143
|
+
if (ok)
|
|
144
|
+
deleteMut.mutate(key.id);
|
|
145
|
+
};
|
|
146
|
+
const keys = list.data?.keys ?? [];
|
|
147
|
+
return (<div className="flex flex-col gap-4">
|
|
148
|
+
<SettingsCard icon={KeyRound} title={t('settings:apiKeys.title')} description={t('settings:apiKeys.description')} action={<Button onClick={onCreate} size="sm">
|
|
149
|
+
<Plus className="size-4"/>
|
|
150
|
+
<span>{t('settings:apiKeys.actions.create')}</span>
|
|
151
|
+
</Button>}>
|
|
152
|
+
<SettingsListState isLoading={list.isLoading} error={list.error} isEmpty={keys.length === 0} loadingLabel={t('common:loading')} empty={{
|
|
153
|
+
icon: KeyRound,
|
|
154
|
+
title: t('settings:apiKeys.empty.title'),
|
|
155
|
+
description: t('settings:apiKeys.empty.description'),
|
|
156
|
+
}}>
|
|
157
|
+
<SettingsTableScroll>
|
|
158
|
+
<Table>
|
|
159
|
+
<TableHeader>
|
|
160
|
+
<TableRow>
|
|
161
|
+
<TableHead>{t('settings:apiKeys.columns.name')}</TableHead>
|
|
162
|
+
<TableHead className="hidden sm:table-cell">{t('settings:apiKeys.columns.start')}</TableHead>
|
|
163
|
+
<TableHead className="hidden md:table-cell">{t('settings:apiKeys.columns.permissions')}</TableHead>
|
|
164
|
+
<TableHead className="hidden md:table-cell">{t('settings:apiKeys.columns.expiresAt')}</TableHead>
|
|
165
|
+
<TableHead>{t('settings:apiKeys.columns.enabled')}</TableHead>
|
|
166
|
+
<TableHead className="text-right">{t('settings:apiKeys.columns.actions')}</TableHead>
|
|
167
|
+
</TableRow>
|
|
168
|
+
</TableHeader>
|
|
169
|
+
<TableBody>
|
|
170
|
+
{keys.map((k) => (<TableRow key={k.id}>
|
|
171
|
+
<TableCell className="font-medium">
|
|
172
|
+
<div className="flex flex-col">
|
|
173
|
+
<span>{k.name ?? k.id}</span>
|
|
174
|
+
{k.lastRequest && (<span className="text-xs text-muted-foreground">
|
|
175
|
+
{t('settings:apiKeys.lastUsed', { date: formatDate(k.lastRequest) })}
|
|
176
|
+
</span>)}
|
|
177
|
+
</div>
|
|
178
|
+
</TableCell>
|
|
179
|
+
<TableCell className="hidden font-mono text-xs sm:table-cell">
|
|
180
|
+
{k.start ? `${k.start}…` : '—'}
|
|
181
|
+
</TableCell>
|
|
182
|
+
<TableCell className="hidden md:table-cell">
|
|
183
|
+
<PermissionsSummary permissions={k.permissions}/>
|
|
184
|
+
</TableCell>
|
|
185
|
+
<TableCell className="hidden md:table-cell text-xs">
|
|
186
|
+
{k.expiresAt ? formatDate(k.expiresAt) : t('settings:apiKeys.expiresNever')}
|
|
187
|
+
</TableCell>
|
|
188
|
+
<TableCell>
|
|
189
|
+
<Switch checked={k.enabled} onCheckedChange={(enabled) => toggleEnabledMut.mutate({ id: k.id, enabled })} aria-label={t('settings:apiKeys.columns.enabled')}/>
|
|
190
|
+
</TableCell>
|
|
191
|
+
<TableCell className="text-right">
|
|
192
|
+
<div className="inline-flex gap-1">
|
|
193
|
+
<Button variant="ghost" size="sm" onClick={() => onEdit(k)} aria-label={t('settings:apiKeys.actions.edit')}>
|
|
194
|
+
<Edit className="size-4"/>
|
|
195
|
+
</Button>
|
|
196
|
+
<Button variant="ghost" size="sm" onClick={() => onRevoke(k)} aria-label={t('settings:apiKeys.actions.revoke')} disabled={deleteMut.isPending}>
|
|
197
|
+
<Trash2 className="size-4 text-destructive"/>
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</TableCell>
|
|
201
|
+
</TableRow>))}
|
|
202
|
+
</TableBody>
|
|
203
|
+
</Table>
|
|
204
|
+
</SettingsTableScroll>
|
|
205
|
+
</SettingsListState>
|
|
206
|
+
</SettingsCard>
|
|
207
|
+
|
|
208
|
+
<ApiKeyEditorDialog key={editing?.id ?? 'new'} open={editorOpen} onOpenChange={setEditorOpen} editing={editing} resources={resources} onCreated={(result) => {
|
|
209
|
+
setEditorOpen(false);
|
|
210
|
+
setCreatedSecret(result);
|
|
211
|
+
qc.invalidateQueries({ queryKey: KEY_LIST });
|
|
212
|
+
}} onUpdated={() => {
|
|
213
|
+
setEditorOpen(false);
|
|
214
|
+
qc.invalidateQueries({ queryKey: KEY_LIST });
|
|
215
|
+
}}/>
|
|
216
|
+
|
|
217
|
+
<CreatedSecretDialog secret={createdSecret} onClose={() => setCreatedSecret(null)}/>
|
|
218
|
+
</div>);
|
|
219
|
+
}
|
|
220
|
+
const buildState = (perms) => ({
|
|
221
|
+
byResource: Object.fromEntries(Object.entries(perms).map(([k, v]) => [k, new Set(v)])),
|
|
222
|
+
});
|
|
223
|
+
const stateToWire = (state) => {
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const [k, set] of Object.entries(state.byResource)) {
|
|
226
|
+
if (set.size === 0)
|
|
227
|
+
continue;
|
|
228
|
+
out[k] = Array.from(set);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
};
|
|
232
|
+
function PermissionsMatrix({ resources, state, onChange, }) {
|
|
233
|
+
const { t } = useI18n();
|
|
234
|
+
const toggle = (resourceId, action) => {
|
|
235
|
+
const set = new Set(state.byResource[resourceId] ?? []);
|
|
236
|
+
if (set.has(action))
|
|
237
|
+
set.delete(action);
|
|
238
|
+
else
|
|
239
|
+
set.add(action);
|
|
240
|
+
onChange({ byResource: { ...state.byResource, [resourceId]: set } });
|
|
241
|
+
};
|
|
242
|
+
const toggleAll = (resourceId, actions) => {
|
|
243
|
+
const current = state.byResource[resourceId] ?? new Set();
|
|
244
|
+
const allSelected = actions.every((a) => current.has(a));
|
|
245
|
+
const next = new Set(allSelected ? [] : actions);
|
|
246
|
+
onChange({ byResource: { ...state.byResource, [resourceId]: next } });
|
|
247
|
+
};
|
|
248
|
+
const resourcesWithActions = React.useMemo(() => resources.filter((r) => (r.actions ?? []).length > 0), [resources]);
|
|
249
|
+
const allGloballySelected = resourcesWithActions.length > 0 &&
|
|
250
|
+
resourcesWithActions.every((r) => {
|
|
251
|
+
const actions = (r.actions ?? []).map((a) => a.name);
|
|
252
|
+
const current = state.byResource[r.id] ?? new Set();
|
|
253
|
+
return actions.every((a) => current.has(a));
|
|
254
|
+
});
|
|
255
|
+
const toggleAllResources = () => {
|
|
256
|
+
const select = !allGloballySelected;
|
|
257
|
+
const next = { ...state.byResource };
|
|
258
|
+
for (const r of resources) {
|
|
259
|
+
const actions = (r.actions ?? []).map((a) => a.name);
|
|
260
|
+
next[r.id] = select ? new Set(actions) : new Set();
|
|
261
|
+
}
|
|
262
|
+
onChange({ byResource: next });
|
|
263
|
+
};
|
|
264
|
+
if (resources.length === 0) {
|
|
265
|
+
return (<p className="text-sm text-muted-foreground">{t('settings:apiKeys.permissions.noResources')}</p>);
|
|
266
|
+
}
|
|
267
|
+
return (<div className="rounded-md border border-border">
|
|
268
|
+
{resourcesWithActions.length > 0 && (<div className="flex flex-wrap items-center justify-end gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
|
269
|
+
<Button type="button" variant="outline" size="sm" className="h-8" onClick={toggleAllResources}>
|
|
270
|
+
{allGloballySelected ? (<>
|
|
271
|
+
<X className="mr-1.5 size-3.5"/>
|
|
272
|
+
{t('settings:apiKeys.permissions.clearAllResources')}
|
|
273
|
+
</>) : (<>
|
|
274
|
+
<Check className="mr-1.5 size-3.5"/>
|
|
275
|
+
{t('settings:apiKeys.permissions.selectAllResources')}
|
|
276
|
+
</>)}
|
|
277
|
+
</Button>
|
|
278
|
+
</div>)}
|
|
279
|
+
<div className="max-h-[24rem] overflow-y-auto">
|
|
280
|
+
<Table>
|
|
281
|
+
<TableHeader className="sticky top-0 bg-card">
|
|
282
|
+
<TableRow>
|
|
283
|
+
<TableHead className="w-[14rem]">{t('settings:apiKeys.permissions.resource')}</TableHead>
|
|
284
|
+
<TableHead>{t('settings:apiKeys.permissions.actions')}</TableHead>
|
|
285
|
+
</TableRow>
|
|
286
|
+
</TableHeader>
|
|
287
|
+
<TableBody>
|
|
288
|
+
{resources.map((r) => {
|
|
289
|
+
const actions = (r.actions ?? []).map((a) => a.name);
|
|
290
|
+
const current = state.byResource[r.id] ?? new Set();
|
|
291
|
+
const allSelected = actions.length > 0 && actions.every((a) => current.has(a));
|
|
292
|
+
return (<TableRow key={r.id}>
|
|
293
|
+
<TableCell className="align-top">
|
|
294
|
+
<div className="flex flex-col gap-1">
|
|
295
|
+
<span className="font-medium">{r.name}</span>
|
|
296
|
+
<span className="text-xs text-muted-foreground">{r.id}</span>
|
|
297
|
+
<button type="button" className="mt-1 inline-flex items-center gap-1 self-start text-xs text-primary hover:underline" onClick={() => toggleAll(r.id, actions)}>
|
|
298
|
+
{allSelected ? <X className="size-3"/> : <Check className="size-3"/>}
|
|
299
|
+
{allSelected
|
|
300
|
+
? t('settings:apiKeys.permissions.clearAll')
|
|
301
|
+
: t('settings:apiKeys.permissions.selectAll')}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</TableCell>
|
|
305
|
+
<TableCell>
|
|
306
|
+
<div className="flex flex-wrap gap-2">
|
|
307
|
+
{actions.map((a) => {
|
|
308
|
+
const checked = current.has(a);
|
|
309
|
+
return (<label key={a} className={cn('inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs', checked && 'border-primary bg-primary/10 text-primary')}>
|
|
310
|
+
<Checkbox checked={checked} onCheckedChange={() => toggle(r.id, a)} className="size-3.5"/>
|
|
311
|
+
<span>{a}</span>
|
|
312
|
+
</label>);
|
|
313
|
+
})}
|
|
314
|
+
</div>
|
|
315
|
+
</TableCell>
|
|
316
|
+
</TableRow>);
|
|
317
|
+
})}
|
|
318
|
+
</TableBody>
|
|
319
|
+
</Table>
|
|
320
|
+
</div>
|
|
321
|
+
</div>);
|
|
322
|
+
}
|
|
323
|
+
function PermissionsSummary({ permissions }) {
|
|
324
|
+
const { t } = useI18n();
|
|
325
|
+
const entries = Object.entries(permissions);
|
|
326
|
+
if (entries.length === 0) {
|
|
327
|
+
return <Badge variant="outline">{t('settings:apiKeys.permissions.none')}</Badge>;
|
|
328
|
+
}
|
|
329
|
+
const totalActions = entries.reduce((sum, [, a]) => sum + a.length, 0);
|
|
330
|
+
return (<Badge variant="secondary">
|
|
331
|
+
{t('settings:apiKeys.permissions.summary', { resources: entries.length, actions: totalActions })}
|
|
332
|
+
</Badge>);
|
|
333
|
+
}
|
|
334
|
+
// ─── Editor dialog ────────────────────────────────────────────────────────────
|
|
335
|
+
function ApiKeyEditorDialog({ open, onOpenChange, editing, resources, onCreated, onUpdated, }) {
|
|
336
|
+
const { t } = useI18n();
|
|
337
|
+
const client = useAdminClient();
|
|
338
|
+
const notify = useNotify();
|
|
339
|
+
const isEdit = !!editing;
|
|
340
|
+
const [name, setName] = React.useState(editing?.name ?? '');
|
|
341
|
+
const [expiresInDays, setExpiresInDays] = React.useState(() => {
|
|
342
|
+
if (!editing?.expiresAt)
|
|
343
|
+
return '';
|
|
344
|
+
const ms = new Date(editing.expiresAt).getTime() - Date.now();
|
|
345
|
+
return ms > 0 ? String(Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)))) : '';
|
|
346
|
+
});
|
|
347
|
+
const [permissions, setPermissions] = React.useState(() => buildState(editing?.permissions ?? {}));
|
|
348
|
+
const save = useMutation({
|
|
349
|
+
mutationFn: async () => {
|
|
350
|
+
const wire = stateToWire(permissions);
|
|
351
|
+
if (isEdit && editing) {
|
|
352
|
+
const expiry = expiresInDays.trim() === '' ? null : Number(expiresInDays);
|
|
353
|
+
const res = await client.updateApiKey(editing.id, {
|
|
354
|
+
name: name.trim(),
|
|
355
|
+
permissions: wire,
|
|
356
|
+
expiresInDays: expiry === null ? null : Number.isFinite(expiry) && expiry > 0 ? expiry : undefined,
|
|
357
|
+
});
|
|
358
|
+
return { record: res.record };
|
|
359
|
+
}
|
|
360
|
+
const expiry = expiresInDays.trim() === '' ? null : Number(expiresInDays);
|
|
361
|
+
return client.createApiKey({
|
|
362
|
+
name: name.trim(),
|
|
363
|
+
permissions: wire,
|
|
364
|
+
expiresInDays: expiry === null ? null : Number.isFinite(expiry) && expiry > 0 ? expiry : undefined,
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
onSuccess: (result) => {
|
|
368
|
+
if (isEdit) {
|
|
369
|
+
notify.success({ key: 'settings:apiKeys.notice.updated' });
|
|
370
|
+
onUpdated(result.record);
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
if (result.key)
|
|
374
|
+
onCreated({ key: result.key, record: result.record });
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
378
|
+
});
|
|
379
|
+
// Sync state when dialog re-opens against a different record.
|
|
380
|
+
React.useEffect(() => {
|
|
381
|
+
if (!open)
|
|
382
|
+
return;
|
|
383
|
+
setName(editing?.name ?? '');
|
|
384
|
+
setPermissions(buildState(editing?.permissions ?? {}));
|
|
385
|
+
if (!editing?.expiresAt)
|
|
386
|
+
setExpiresInDays('');
|
|
387
|
+
else {
|
|
388
|
+
const ms = new Date(editing.expiresAt).getTime() - Date.now();
|
|
389
|
+
setExpiresInDays(ms > 0 ? String(Math.max(1, Math.round(ms / (24 * 60 * 60 * 1000)))) : '');
|
|
390
|
+
}
|
|
391
|
+
}, [open, editing]);
|
|
392
|
+
const totalSelected = Object.values(permissions.byResource).reduce((sum, set) => sum + (set?.size ?? 0), 0);
|
|
393
|
+
return (<Dialog open={open} onOpenChange={onOpenChange}>
|
|
394
|
+
<DialogContent className="max-w-3xl">
|
|
395
|
+
<DialogHeader>
|
|
396
|
+
<DialogTitle>
|
|
397
|
+
{isEdit ? t('settings:apiKeys.editor.titleEdit') : t('settings:apiKeys.editor.titleCreate')}
|
|
398
|
+
</DialogTitle>
|
|
399
|
+
<DialogDescription>{t('settings:apiKeys.editor.description')}</DialogDescription>
|
|
400
|
+
</DialogHeader>
|
|
401
|
+
<form className="flex flex-col gap-4" onSubmit={(e) => {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
if (!name.trim())
|
|
404
|
+
return;
|
|
405
|
+
save.mutate();
|
|
406
|
+
}}>
|
|
407
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
408
|
+
<div className="flex flex-col gap-1.5">
|
|
409
|
+
<Label htmlFor="api-key-name">{t('settings:apiKeys.editor.name')}</Label>
|
|
410
|
+
<Input id="api-key-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings:apiKeys.editor.namePlaceholder')} required maxLength={64}/>
|
|
411
|
+
</div>
|
|
412
|
+
<div className="flex flex-col gap-1.5">
|
|
413
|
+
<Label htmlFor="api-key-expires">{t('settings:apiKeys.editor.expiresInDays')}</Label>
|
|
414
|
+
<Input id="api-key-expires" type="number" min={1} max={3650} value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} placeholder={t('settings:apiKeys.editor.expiresPlaceholder')}/>
|
|
415
|
+
<span className="text-xs text-muted-foreground">
|
|
416
|
+
{t('settings:apiKeys.editor.expiresHint')}
|
|
417
|
+
</span>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
<div className="flex flex-col gap-2">
|
|
421
|
+
<div className="flex items-center justify-between">
|
|
422
|
+
<Label>{t('settings:apiKeys.editor.permissions')}</Label>
|
|
423
|
+
<span className="text-xs text-muted-foreground">
|
|
424
|
+
{t('settings:apiKeys.editor.selectedActions', { count: totalSelected })}
|
|
425
|
+
</span>
|
|
426
|
+
</div>
|
|
427
|
+
<PermissionsMatrix resources={resources} state={permissions} onChange={setPermissions}/>
|
|
428
|
+
</div>
|
|
429
|
+
<DialogFooter>
|
|
430
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
431
|
+
{t('common:cancel')}
|
|
432
|
+
</Button>
|
|
433
|
+
<Button type="submit" disabled={save.isPending || !name.trim() || totalSelected === 0}>
|
|
434
|
+
{save.isPending ? t('common:saving') : isEdit ? t('common:save') : t('settings:apiKeys.actions.create')}
|
|
435
|
+
</Button>
|
|
436
|
+
</DialogFooter>
|
|
437
|
+
</form>
|
|
438
|
+
</DialogContent>
|
|
439
|
+
</Dialog>);
|
|
440
|
+
}
|
|
441
|
+
// ─── Webhooks section ─────────────────────────────────────────────────────────
|
|
442
|
+
const WEBHOOK_EVENTS = ['record.created', 'record.updated', 'record.deleted', '*'];
|
|
443
|
+
function WebhooksSection() {
|
|
444
|
+
const { t } = useI18n();
|
|
445
|
+
const client = useAdminClient();
|
|
446
|
+
const qc = useQueryClient();
|
|
447
|
+
const notify = useNotify();
|
|
448
|
+
const dialogs = useDialogs();
|
|
449
|
+
const resources = useResources();
|
|
450
|
+
const [editorOpen, setEditorOpen] = React.useState(false);
|
|
451
|
+
const [editing, setEditing] = React.useState(null);
|
|
452
|
+
const [selectedId, setSelectedId] = React.useState(null);
|
|
453
|
+
const list = useQuery({
|
|
454
|
+
queryKey: KEY_WEBHOOKS,
|
|
455
|
+
queryFn: () => client.listWebhooks(),
|
|
456
|
+
});
|
|
457
|
+
const deliveries = useQuery({
|
|
458
|
+
queryKey: ['modern-admin', 'webhooks', selectedId, 'deliveries'],
|
|
459
|
+
queryFn: () => client.listWebhookDeliveries(selectedId),
|
|
460
|
+
enabled: !!selectedId,
|
|
461
|
+
});
|
|
462
|
+
const deleteMut = useMutation({
|
|
463
|
+
mutationFn: (id) => client.deleteWebhook(id),
|
|
464
|
+
onSuccess: () => {
|
|
465
|
+
qc.invalidateQueries({ queryKey: KEY_WEBHOOKS });
|
|
466
|
+
notify.success({ key: 'settings:webhooks.notice.deleted' });
|
|
467
|
+
},
|
|
468
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
469
|
+
});
|
|
470
|
+
const testMut = useMutation({
|
|
471
|
+
mutationFn: (id) => client.testWebhook(id),
|
|
472
|
+
onSuccess: () => {
|
|
473
|
+
if (selectedId)
|
|
474
|
+
qc.invalidateQueries({ queryKey: ['modern-admin', 'webhooks', selectedId, 'deliveries'] });
|
|
475
|
+
notify.success({ key: 'settings:webhooks.notice.testQueued' });
|
|
476
|
+
},
|
|
477
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
478
|
+
});
|
|
479
|
+
const webhooks = list.data?.webhooks ?? [];
|
|
480
|
+
const onDelete = async (webhook) => {
|
|
481
|
+
const ok = await dialogs.confirm({
|
|
482
|
+
title: t('settings:webhooks.confirmDelete.title'),
|
|
483
|
+
description: t('settings:webhooks.confirmDelete.description', { name: webhook.name }),
|
|
484
|
+
confirmLabel: t('common:delete'),
|
|
485
|
+
destructive: true,
|
|
486
|
+
});
|
|
487
|
+
if (ok)
|
|
488
|
+
deleteMut.mutate(webhook.id);
|
|
489
|
+
};
|
|
490
|
+
return (<div className="flex flex-col gap-4">
|
|
491
|
+
<SettingsCard icon={SettingsIcon} title={t('settings:webhooks.title')} description={t('settings:webhooks.description')} action={<Button size="sm" onClick={() => { setEditing(null); setEditorOpen(true); }}>
|
|
492
|
+
<Plus className="size-4"/>
|
|
493
|
+
{t('settings:webhooks.actions.create')}
|
|
494
|
+
</Button>}>
|
|
495
|
+
<SettingsListState isLoading={list.isLoading} error={list.error} isEmpty={webhooks.length === 0} loadingLabel={t('common:loading')} empty={{
|
|
496
|
+
icon: SettingsIcon,
|
|
497
|
+
title: t('settings:webhooks.empty.title'),
|
|
498
|
+
description: t('settings:webhooks.empty.description'),
|
|
499
|
+
}}>
|
|
500
|
+
<SettingsTableScroll>
|
|
501
|
+
<Table>
|
|
502
|
+
<TableHeader>
|
|
503
|
+
<TableRow>
|
|
504
|
+
<TableHead>{t('settings:webhooks.columns.name')}</TableHead>
|
|
505
|
+
<TableHead>{t('settings:webhooks.columns.resource')}</TableHead>
|
|
506
|
+
<TableHead>{t('settings:webhooks.columns.events')}</TableHead>
|
|
507
|
+
<TableHead>{t('settings:webhooks.columns.enabled')}</TableHead>
|
|
508
|
+
<TableHead className="text-right">{t('settings:webhooks.columns.actions')}</TableHead>
|
|
509
|
+
</TableRow>
|
|
510
|
+
</TableHeader>
|
|
511
|
+
<TableBody>
|
|
512
|
+
{webhooks.map((webhook) => (<TableRow key={webhook.id}>
|
|
513
|
+
<TableCell>
|
|
514
|
+
<button type="button" className="text-left font-medium hover:underline" onClick={() => setSelectedId(webhook.id)}>
|
|
515
|
+
{webhook.name}
|
|
516
|
+
</button>
|
|
517
|
+
<div className="max-w-xs truncate text-xs text-muted-foreground">{webhook.url}</div>
|
|
518
|
+
</TableCell>
|
|
519
|
+
<TableCell>{resourceName(resources, webhook.resourceId, t('settings:webhooks.editor.allResources'))}</TableCell>
|
|
520
|
+
<TableCell className="max-w-xs">
|
|
521
|
+
<div className="flex flex-wrap gap-1">
|
|
522
|
+
{webhook.events.map((event) => <Badge key={event} variant="outline">{event}</Badge>)}
|
|
523
|
+
</div>
|
|
524
|
+
</TableCell>
|
|
525
|
+
<TableCell>{webhook.enabled ? t('common:yes') : t('common:no')}</TableCell>
|
|
526
|
+
<TableCell className="text-right">
|
|
527
|
+
<div className="inline-flex gap-1">
|
|
528
|
+
<Button variant="ghost" size="sm" onClick={() => testMut.mutate(webhook.id)}>
|
|
529
|
+
{t('settings:webhooks.actions.test')}
|
|
530
|
+
</Button>
|
|
531
|
+
<Button variant="ghost" size="sm" onClick={() => { setEditing(webhook); setEditorOpen(true); }}>
|
|
532
|
+
<Edit className="size-4"/>
|
|
533
|
+
</Button>
|
|
534
|
+
<Button variant="ghost" size="sm" onClick={() => void onDelete(webhook)}>
|
|
535
|
+
<Trash2 className="size-4 text-destructive"/>
|
|
536
|
+
</Button>
|
|
537
|
+
</div>
|
|
538
|
+
</TableCell>
|
|
539
|
+
</TableRow>))}
|
|
540
|
+
</TableBody>
|
|
541
|
+
</Table>
|
|
542
|
+
</SettingsTableScroll>
|
|
543
|
+
</SettingsListState>
|
|
544
|
+
</SettingsCard>
|
|
545
|
+
|
|
546
|
+
{selectedId && (<SettingsCard icon={SettingsIcon} title={t('settings:webhooks.deliveries.title')}>
|
|
547
|
+
{deliveries.isLoading ? (<div className="py-4 text-sm text-muted-foreground">{t('common:loading')}</div>) : (<SettingsTableScroll>
|
|
548
|
+
<Table>
|
|
549
|
+
<TableHeader>
|
|
550
|
+
<TableRow>
|
|
551
|
+
<TableHead>{t('settings:webhooks.deliveries.status')}</TableHead>
|
|
552
|
+
<TableHead>{t('settings:webhooks.deliveries.event')}</TableHead>
|
|
553
|
+
<TableHead>{t('settings:webhooks.deliveries.attempt')}</TableHead>
|
|
554
|
+
<TableHead>{t('settings:webhooks.deliveries.response')}</TableHead>
|
|
555
|
+
<TableHead>{t('settings:webhooks.deliveries.createdAt')}</TableHead>
|
|
556
|
+
</TableRow>
|
|
557
|
+
</TableHeader>
|
|
558
|
+
<TableBody>
|
|
559
|
+
{(deliveries.data?.deliveries ?? []).map((delivery) => (<TableRow key={delivery.id}>
|
|
560
|
+
<TableCell>{delivery.status}</TableCell>
|
|
561
|
+
<TableCell>{delivery.event}</TableCell>
|
|
562
|
+
<TableCell>{delivery.attempt}</TableCell>
|
|
563
|
+
<TableCell className="max-w-sm truncate">
|
|
564
|
+
{delivery.responseStatus ?? delivery.error ?? delivery.responseBody ?? '—'}
|
|
565
|
+
</TableCell>
|
|
566
|
+
<TableCell>{formatDate(delivery.createdAt)}</TableCell>
|
|
567
|
+
</TableRow>))}
|
|
568
|
+
</TableBody>
|
|
569
|
+
</Table>
|
|
570
|
+
</SettingsTableScroll>)}
|
|
571
|
+
</SettingsCard>)}
|
|
572
|
+
|
|
573
|
+
<WebhookEditorDialog key={editing?.id ?? 'new'} open={editorOpen} onOpenChange={setEditorOpen} webhook={editing} resources={resources}/>
|
|
574
|
+
</div>);
|
|
575
|
+
}
|
|
576
|
+
function WebhookEditorDialog({ open, onOpenChange, webhook, resources, }) {
|
|
577
|
+
const { t } = useI18n();
|
|
578
|
+
const client = useAdminClient();
|
|
579
|
+
const qc = useQueryClient();
|
|
580
|
+
const notify = useNotify();
|
|
581
|
+
const [name, setName] = React.useState(webhook?.name ?? '');
|
|
582
|
+
const [url, setUrl] = React.useState(webhook?.url ?? '');
|
|
583
|
+
const [resourceId, setResourceId] = React.useState(webhook?.resourceId ?? '');
|
|
584
|
+
const [enabled, setEnabled] = React.useState(webhook?.enabled ?? true);
|
|
585
|
+
const [secret, setSecret] = React.useState(webhook?.secret ?? '');
|
|
586
|
+
const [events, setEvents] = React.useState(webhook?.events ?? ['record.created', 'record.updated']);
|
|
587
|
+
const [headers, setHeaders] = React.useState(webhook?.headers ?? {});
|
|
588
|
+
const [filters, setFilters] = React.useState(webhook?.filters ?? {});
|
|
589
|
+
const [payloadFields, setPayloadFields] = React.useState(webhook?.payloadFields ?? []);
|
|
590
|
+
const selectedResource = resources.find((r) => r.id === resourceId);
|
|
591
|
+
const save = useMutation({
|
|
592
|
+
mutationFn: (payload) => webhook ? client.updateWebhook(webhook.id, payload) : client.createWebhook(payload),
|
|
593
|
+
onSuccess: () => {
|
|
594
|
+
qc.invalidateQueries({ queryKey: KEY_WEBHOOKS });
|
|
595
|
+
notify.success({ key: 'settings:webhooks.notice.saved' });
|
|
596
|
+
onOpenChange(false);
|
|
597
|
+
},
|
|
598
|
+
onError: (err) => notify.error({ message: err instanceof Error ? err.message : String(err) }),
|
|
599
|
+
});
|
|
600
|
+
const toggleEvent = (event) => {
|
|
601
|
+
setEvents((prev) => prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]);
|
|
602
|
+
};
|
|
603
|
+
const onSubmit = (e) => {
|
|
604
|
+
e.preventDefault();
|
|
605
|
+
save.mutate({
|
|
606
|
+
name: name.trim(),
|
|
607
|
+
url: url.trim(),
|
|
608
|
+
events,
|
|
609
|
+
resourceId: resourceId || null,
|
|
610
|
+
enabled,
|
|
611
|
+
...(secret.trim() ? { secret: secret.trim() } : {}),
|
|
612
|
+
headers: stringRecord(headers),
|
|
613
|
+
filters: stringRecord(filters),
|
|
614
|
+
payloadFields,
|
|
615
|
+
});
|
|
616
|
+
};
|
|
617
|
+
return (<Dialog open={open} onOpenChange={onOpenChange}>
|
|
618
|
+
<DialogContent className="max-w-3xl">
|
|
619
|
+
<DialogHeader>
|
|
620
|
+
<DialogTitle>{webhook ? t('settings:webhooks.editor.titleEdit') : t('settings:webhooks.editor.titleCreate')}</DialogTitle>
|
|
621
|
+
<DialogDescription>{t('settings:webhooks.editor.description')}</DialogDescription>
|
|
622
|
+
</DialogHeader>
|
|
623
|
+
<form className="space-y-4" onSubmit={onSubmit}>
|
|
624
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
625
|
+
<div className="space-y-1.5">
|
|
626
|
+
<Label>{t('settings:webhooks.editor.name')}</Label>
|
|
627
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} required/>
|
|
628
|
+
</div>
|
|
629
|
+
<div className="space-y-1.5">
|
|
630
|
+
<Label>{t('settings:webhooks.editor.url')}</Label>
|
|
631
|
+
<Input value={url} onChange={(e) => setUrl(e.target.value)} required type="url"/>
|
|
632
|
+
</div>
|
|
633
|
+
<div className="space-y-1.5">
|
|
634
|
+
<Label>{t('settings:webhooks.editor.resource')}</Label>
|
|
635
|
+
<Select value={resourceId || '__all__'} onValueChange={(v) => { setResourceId(v === '__all__' ? '' : v); setPayloadFields([]); }}>
|
|
636
|
+
<SelectTrigger>
|
|
637
|
+
<SelectValue />
|
|
638
|
+
</SelectTrigger>
|
|
639
|
+
<SelectContent>
|
|
640
|
+
<SelectItem value="__all__">{t('settings:webhooks.editor.allResources')}</SelectItem>
|
|
641
|
+
{resources.map((resource) => (<SelectItem key={resource.id} value={resource.id}>
|
|
642
|
+
{resource.name}
|
|
643
|
+
{resource.name !== resource.id && (<span className="ml-1.5 text-xs text-muted-foreground">({resource.id})</span>)}
|
|
644
|
+
</SelectItem>))}
|
|
645
|
+
</SelectContent>
|
|
646
|
+
</Select>
|
|
647
|
+
</div>
|
|
648
|
+
<div className="space-y-1.5">
|
|
649
|
+
<div className="flex items-center gap-1.5">
|
|
650
|
+
<Label>{t('settings:webhooks.editor.secret')}</Label>
|
|
651
|
+
<InfoTooltip content={t('settings:webhooks.editor.secretHint')} ariaLabel={t('settings:webhooks.editor.secret')} side="right"/>
|
|
652
|
+
</div>
|
|
653
|
+
<Input value={secret} onChange={(e) => setSecret(e.target.value)} type="password"/>
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
<div className="flex items-center gap-2">
|
|
657
|
+
<Switch checked={enabled} onCheckedChange={setEnabled}/>
|
|
658
|
+
<Label>{t('settings:webhooks.editor.enabled')}</Label>
|
|
659
|
+
</div>
|
|
660
|
+
<div className="space-y-2">
|
|
661
|
+
<Label>{t('settings:webhooks.editor.events')}</Label>
|
|
662
|
+
<div className="flex flex-wrap gap-2">
|
|
663
|
+
{WEBHOOK_EVENTS.map((event) => (<label key={event} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
|
664
|
+
<Checkbox checked={events.includes(event)} onCheckedChange={() => toggleEvent(event)}/>
|
|
665
|
+
{event}
|
|
666
|
+
</label>))}
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
{selectedResource && (<div className="space-y-2">
|
|
670
|
+
<div className="flex items-center gap-1.5">
|
|
671
|
+
<Label>{t('settings:webhooks.editor.payloadFields')}</Label>
|
|
672
|
+
<InfoTooltip content={t('settings:webhooks.editor.payloadFieldsHint')} ariaLabel={t('settings:webhooks.editor.payloadFields')} side="right"/>
|
|
673
|
+
</div>
|
|
674
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
675
|
+
{selectedResource.properties.map((property) => (<label key={property.path} className="flex items-center gap-2 text-sm">
|
|
676
|
+
<Checkbox checked={payloadFields.includes(property.path)} onCheckedChange={() => setPayloadFields((prev) => prev.includes(property.path)
|
|
677
|
+
? prev.filter((p) => p !== property.path)
|
|
678
|
+
: [...prev, property.path])}/>
|
|
679
|
+
{property.label}
|
|
680
|
+
</label>))}
|
|
681
|
+
</div>
|
|
682
|
+
</div>)}
|
|
683
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
684
|
+
<div className="space-y-1.5">
|
|
685
|
+
<div className="flex items-center gap-1.5">
|
|
686
|
+
<Label>{t('settings:webhooks.editor.headers')}</Label>
|
|
687
|
+
<InfoTooltip content={t('settings:webhooks.editor.headersHint')} ariaLabel={t('settings:webhooks.editor.headers')} side="top"/>
|
|
688
|
+
</div>
|
|
689
|
+
<JsonEditor value={headers} onChange={(next) => setHeaders(toJsonRecord(next))}/>
|
|
690
|
+
</div>
|
|
691
|
+
<div className="space-y-1.5">
|
|
692
|
+
<div className="flex items-center gap-1.5">
|
|
693
|
+
<Label>{t('settings:webhooks.editor.filters')}</Label>
|
|
694
|
+
<InfoTooltip content={t('settings:webhooks.editor.filtersHint')} ariaLabel={t('settings:webhooks.editor.filters')} side="top"/>
|
|
695
|
+
</div>
|
|
696
|
+
<JsonEditor value={filters} onChange={(next) => setFilters(toJsonRecord(next))}/>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
<DialogFooter>
|
|
700
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>{t('common:cancel')}</Button>
|
|
701
|
+
<Button type="submit" disabled={save.isPending || !name.trim() || !url.trim() || events.length === 0}>
|
|
702
|
+
{save.isPending ? t('common:saving') : t('common:save')}
|
|
703
|
+
</Button>
|
|
704
|
+
</DialogFooter>
|
|
705
|
+
</form>
|
|
706
|
+
</DialogContent>
|
|
707
|
+
</Dialog>);
|
|
708
|
+
}
|
|
709
|
+
const resourceName = (resources, id, fallback) => id ? (resources.find((r) => r.id === id)?.name ?? id) : fallback;
|
|
710
|
+
const toJsonRecord = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
711
|
+
const stringRecord = (value) => {
|
|
712
|
+
const out = {};
|
|
713
|
+
for (const [key, item] of Object.entries(value)) {
|
|
714
|
+
if (item != null && item !== '')
|
|
715
|
+
out[key] = String(item);
|
|
716
|
+
}
|
|
717
|
+
return out;
|
|
718
|
+
};
|
|
719
|
+
// ─── Created-secret dialog ────────────────────────────────────────────────────
|
|
720
|
+
function CreatedSecretDialog({ secret, onClose, }) {
|
|
721
|
+
const { t } = useI18n();
|
|
722
|
+
const notify = useNotify();
|
|
723
|
+
const [reveal, setReveal] = React.useState(false);
|
|
724
|
+
const [copied, setCopied] = React.useState(false);
|
|
725
|
+
React.useEffect(() => {
|
|
726
|
+
if (!secret) {
|
|
727
|
+
setReveal(false);
|
|
728
|
+
setCopied(false);
|
|
729
|
+
}
|
|
730
|
+
}, [secret]);
|
|
731
|
+
const onCopy = async () => {
|
|
732
|
+
if (!secret)
|
|
733
|
+
return;
|
|
734
|
+
try {
|
|
735
|
+
await navigator.clipboard.writeText(secret.key);
|
|
736
|
+
setCopied(true);
|
|
737
|
+
notify.success({ key: 'settings:apiKeys.notice.copied' });
|
|
738
|
+
setTimeout(() => setCopied(false), 1500);
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
notify.error({ key: 'settings:apiKeys.notice.copyFailed' });
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
return (<Dialog open={!!secret} onOpenChange={(open) => !open && onClose()}>
|
|
745
|
+
<DialogContent className="max-w-lg">
|
|
746
|
+
<DialogHeader>
|
|
747
|
+
<DialogTitle className="flex items-center gap-2">
|
|
748
|
+
<SettingsIcon className="size-5"/>
|
|
749
|
+
{t('settings:apiKeys.created.title')}
|
|
750
|
+
</DialogTitle>
|
|
751
|
+
<DialogDescription>{t('settings:apiKeys.created.description')}</DialogDescription>
|
|
752
|
+
</DialogHeader>
|
|
753
|
+
<div className="rounded-md border border-amber-300/60 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-300/30 dark:bg-amber-950/40 dark:text-amber-200">
|
|
754
|
+
<div className="flex items-start gap-2">
|
|
755
|
+
<AlertTriangle className="mt-0.5 size-4"/>
|
|
756
|
+
<p>{t('settings:apiKeys.created.warning')}</p>
|
|
757
|
+
</div>
|
|
758
|
+
</div>
|
|
759
|
+
<div className="flex items-stretch gap-2">
|
|
760
|
+
<div className="relative flex-1">
|
|
761
|
+
<Input readOnly type={reveal ? 'text' : 'password'} value={secret?.key ?? ''} className="pr-10 font-mono text-xs" onFocus={(e) => e.currentTarget.select()}/>
|
|
762
|
+
<Button type="button" variant="ghost" size="sm" className="absolute right-1 top-1/2 -translate-y-1/2" onClick={() => setReveal((v) => !v)} aria-label={reveal ? t('settings:apiKeys.created.hide') : t('settings:apiKeys.created.reveal')}>
|
|
763
|
+
{reveal ? <EyeOff className="size-4"/> : <Eye className="size-4"/>}
|
|
764
|
+
</Button>
|
|
765
|
+
</div>
|
|
766
|
+
<Button type="button" onClick={onCopy} variant="secondary">
|
|
767
|
+
{copied ? <Check className="size-4"/> : <Copy className="size-4"/>}
|
|
768
|
+
<span className="hidden sm:inline">
|
|
769
|
+
{copied ? t('settings:apiKeys.created.copied') : t('settings:apiKeys.created.copy')}
|
|
770
|
+
</span>
|
|
771
|
+
</Button>
|
|
772
|
+
</div>
|
|
773
|
+
<DialogFooter>
|
|
774
|
+
<Button onClick={onClose}>{t('common:done')}</Button>
|
|
775
|
+
</DialogFooter>
|
|
776
|
+
</DialogContent>
|
|
777
|
+
</Dialog>);
|
|
778
|
+
}
|
|
779
|
+
const formatDate = (input) => {
|
|
780
|
+
try {
|
|
781
|
+
return new Date(input).toLocaleString();
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return String(input);
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
//# sourceMappingURL=settings-page.jsx.map
|