strapi-mcp-server 0.1.1

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +415 -0
  3. package/admin/src/components/PageHeader.tsx +33 -0
  4. package/admin/src/components/Sidebar.tsx +138 -0
  5. package/admin/src/index.tsx +54 -0
  6. package/admin/src/lib/api.ts +27 -0
  7. package/admin/src/lib/applyQuery.ts +152 -0
  8. package/admin/src/pages/App.tsx +126 -0
  9. package/admin/src/pages/AuditLog.tsx +386 -0
  10. package/admin/src/pages/Clients.tsx +465 -0
  11. package/admin/src/pages/EditClient.tsx +248 -0
  12. package/admin/src/pages/HomePage.tsx +378 -0
  13. package/admin/src/pages/NewClient.tsx +244 -0
  14. package/admin/src/pages/Settings.tsx +514 -0
  15. package/admin/src/pages/SsoBridge.tsx +96 -0
  16. package/admin/src/pages/Tools.tsx +68 -0
  17. package/admin/src/pluginId.ts +1 -0
  18. package/admin/src/translations/en.json +8 -0
  19. package/package.json +105 -0
  20. package/server/src/bootstrap.ts +118 -0
  21. package/server/src/config/index.ts +290 -0
  22. package/server/src/content-types/audit-log/index.ts +3 -0
  23. package/server/src/content-types/audit-log/schema.json +32 -0
  24. package/server/src/content-types/index.ts +19 -0
  25. package/server/src/content-types/oauth-auth-code/index.ts +3 -0
  26. package/server/src/content-types/oauth-auth-code/schema.json +31 -0
  27. package/server/src/content-types/oauth-client/index.ts +3 -0
  28. package/server/src/content-types/oauth-client/schema.json +33 -0
  29. package/server/src/content-types/oauth-consent/index.ts +3 -0
  30. package/server/src/content-types/oauth-consent/schema.json +21 -0
  31. package/server/src/content-types/oauth-refresh-token/index.ts +3 -0
  32. package/server/src/content-types/oauth-refresh-token/schema.json +25 -0
  33. package/server/src/content-types/oauth-revocation/index.ts +3 -0
  34. package/server/src/content-types/oauth-revocation/schema.json +18 -0
  35. package/server/src/content-types/oauth-signing-key/index.ts +3 -0
  36. package/server/src/content-types/oauth-signing-key/schema.json +21 -0
  37. package/server/src/controllers/admin/audit.ts +30 -0
  38. package/server/src/controllers/admin/clients.ts +148 -0
  39. package/server/src/controllers/admin/dashboard.ts +28 -0
  40. package/server/src/controllers/admin/index.ts +15 -0
  41. package/server/src/controllers/admin/settings.ts +38 -0
  42. package/server/src/controllers/admin/tools.ts +23 -0
  43. package/server/src/controllers/index.ts +13 -0
  44. package/server/src/controllers/mcp.ts +168 -0
  45. package/server/src/controllers/oauth/authorize.ts +418 -0
  46. package/server/src/controllers/oauth/index.ts +15 -0
  47. package/server/src/controllers/oauth/introspect.ts +45 -0
  48. package/server/src/controllers/oauth/metadata.ts +86 -0
  49. package/server/src/controllers/oauth/mode-guard.ts +22 -0
  50. package/server/src/controllers/oauth/register.ts +109 -0
  51. package/server/src/controllers/oauth/token.ts +206 -0
  52. package/server/src/controllers/proxy.ts +81 -0
  53. package/server/src/destroy.ts +28 -0
  54. package/server/src/index.ts +23 -0
  55. package/server/src/policies/authenticate.ts +81 -0
  56. package/server/src/policies/index.ts +13 -0
  57. package/server/src/policies/origin.ts +50 -0
  58. package/server/src/policies/rateLimit.ts +27 -0
  59. package/server/src/policies/scope.ts +32 -0
  60. package/server/src/register.ts +48 -0
  61. package/server/src/routes/admin.ts +85 -0
  62. package/server/src/routes/index.ts +13 -0
  63. package/server/src/routes/mcp.ts +31 -0
  64. package/server/src/routes/oauth.ts +81 -0
  65. package/server/src/routes/proxy.ts +29 -0
  66. package/server/src/services/audit.ts +158 -0
  67. package/server/src/services/heartbeat.ts +76 -0
  68. package/server/src/services/index.ts +37 -0
  69. package/server/src/services/instance-id.ts +30 -0
  70. package/server/src/services/mcp-server.ts +100 -0
  71. package/server/src/services/oauth/audience.ts +26 -0
  72. package/server/src/services/oauth/auth-codes.ts +78 -0
  73. package/server/src/services/oauth/clients.ts +386 -0
  74. package/server/src/services/oauth/consent.ts +38 -0
  75. package/server/src/services/oauth/errors.ts +32 -0
  76. package/server/src/services/oauth/pkce.ts +34 -0
  77. package/server/src/services/oauth/scopes.ts +42 -0
  78. package/server/src/services/oauth/signing-keys.ts +166 -0
  79. package/server/src/services/oauth/tokens.ts +324 -0
  80. package/server/src/services/permissions.ts +87 -0
  81. package/server/src/services/proxy-client.ts +167 -0
  82. package/server/src/services/rate-limiter.ts +180 -0
  83. package/server/src/services/redis.ts +139 -0
  84. package/server/src/services/session-directory.ts +121 -0
  85. package/server/src/services/session-store.ts +216 -0
  86. package/server/src/services/sso-cookie.ts +146 -0
  87. package/server/src/services/tools/content.ts +284 -0
  88. package/server/src/services/tools/index.ts +23 -0
  89. package/server/src/services/tools/media.ts +170 -0
@@ -0,0 +1,248 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useNavigate, useParams } from 'react-router-dom';
3
+ import {
4
+ Box,
5
+ Button,
6
+ Flex,
7
+ Typography,
8
+ TextInput,
9
+ Textarea,
10
+ Checkbox,
11
+ Grid,
12
+ } from '@strapi/design-system';
13
+ import { ArrowLeft } from '@strapi/icons';
14
+ import { useMcpApi } from '../lib/api';
15
+ import { PageHeader } from '../components/PageHeader';
16
+
17
+ const ALL_SCOPES = [
18
+ { id: 'strapi:content:read', label: 'Read content (list types, schemas, entries)' },
19
+ { id: 'strapi:content:write', label: 'Create / update content entries (draft only)' },
20
+ { id: 'strapi:media:read', label: 'List media files' },
21
+ { id: 'strapi:media:write', label: 'Upload media files' },
22
+ ];
23
+
24
+ interface Client {
25
+ clientId: string;
26
+ clientName: string;
27
+ isConfidential: boolean;
28
+ redirectUris: string[];
29
+ scopes: string[];
30
+ disabled: boolean;
31
+ }
32
+
33
+ export function EditClient(): JSX.Element {
34
+ const api = useMcpApi();
35
+ const navigate = useNavigate();
36
+ const { clientId } = useParams<{ clientId: string }>();
37
+
38
+ const [original, setOriginal] = useState<Client | null>(null);
39
+ const [name, setName] = useState('');
40
+ const [redirects, setRedirects] = useState('');
41
+ const [scopes, setScopes] = useState<string[]>([]);
42
+ const [disabled, setDisabled] = useState(false);
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [submitting, setSubmitting] = useState(false);
45
+ const [loading, setLoading] = useState(true);
46
+
47
+ useEffect(() => {
48
+ if (!clientId) return;
49
+ api
50
+ .getClient(clientId)
51
+ .then((c: Client) => {
52
+ setOriginal(c);
53
+ setName(c.clientName);
54
+ setRedirects((c.redirectUris ?? []).join('\n'));
55
+ setScopes(c.scopes ?? []);
56
+ setDisabled(c.disabled);
57
+ })
58
+ .catch((err: Error) => setError(err.message ?? String(err)))
59
+ .finally(() => setLoading(false));
60
+ }, [clientId]);
61
+
62
+ async function submit(): Promise<void> {
63
+ if (!clientId) return;
64
+ if (!name.trim() || !redirects.trim() || scopes.length === 0) {
65
+ setError('Name, at least one redirect URI, and at least one scope are required.');
66
+ return;
67
+ }
68
+ setSubmitting(true);
69
+ setError(null);
70
+ try {
71
+ await api.updateClient(clientId, {
72
+ clientName: name.trim(),
73
+ redirectUris: redirects
74
+ .split('\n')
75
+ .map((s) => s.trim())
76
+ .filter(Boolean),
77
+ scopes,
78
+ disabled,
79
+ });
80
+ navigate('/plugins/mcp-server/clients');
81
+ } catch (err) {
82
+ setError((err as Error).message);
83
+ } finally {
84
+ setSubmitting(false);
85
+ }
86
+ }
87
+
88
+ if (loading && !original) {
89
+ return (
90
+ <Box>
91
+ <PageHeader title="Edit client" />
92
+ <Typography>Loading…</Typography>
93
+ </Box>
94
+ );
95
+ }
96
+
97
+ if (!original) {
98
+ return (
99
+ <Box>
100
+ <PageHeader title="Edit client" />
101
+ <Box background="danger100" padding={4} hasRadius>
102
+ <Typography textColor="danger700">
103
+ {error ?? 'Client not found'}
104
+ </Typography>
105
+ </Box>
106
+ </Box>
107
+ );
108
+ }
109
+
110
+ return (
111
+ <Box>
112
+ <PageHeader
113
+ title="Edit client"
114
+ subtitle={`Update settings for ${original.clientName}`}
115
+ actions={
116
+ <Button
117
+ variant="tertiary"
118
+ startIcon={<ArrowLeft />}
119
+ onClick={() => navigate('/plugins/mcp-server/clients')}
120
+ >
121
+ Back
122
+ </Button>
123
+ }
124
+ />
125
+
126
+ {error && (
127
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
128
+ <Typography textColor="danger700">{error}</Typography>
129
+ </Box>
130
+ )}
131
+
132
+ <Box background="neutral0" padding={6} hasRadius shadow="tableShadow">
133
+ <Flex direction="column" gap={6} alignItems="stretch">
134
+ <Grid.Root gap={6}>
135
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
136
+ <Box>
137
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
138
+ Client name
139
+ </Typography>
140
+ <Box paddingTop={1}>
141
+ <TextInput
142
+ name="clientName"
143
+ value={name}
144
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
145
+ setName(e.target.value)
146
+ }
147
+ />
148
+ </Box>
149
+ </Box>
150
+ </Grid.Item>
151
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
152
+ <Box>
153
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
154
+ Client ID
155
+ </Typography>
156
+ <Box paddingTop={1}>
157
+ <TextInput name="clientId" value={original.clientId} disabled aria-readonly />
158
+ </Box>
159
+ </Box>
160
+ </Grid.Item>
161
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
162
+ <Box paddingTop={3}>
163
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
164
+ Type
165
+ </Typography>
166
+ <Box paddingTop={2}>
167
+ <Typography variant="omega" textColor="neutral700">
168
+ {original.isConfidential
169
+ ? 'Confidential (cannot be changed after creation)'
170
+ : 'Public (cannot be changed after creation)'}
171
+ </Typography>
172
+ </Box>
173
+ </Box>
174
+ </Grid.Item>
175
+ <Grid.Item col={6} s={12} direction="column" alignItems="stretch">
176
+ <Box paddingTop={6}>
177
+ <Checkbox
178
+ checked={disabled}
179
+ onCheckedChange={(c: boolean | 'indeterminate') => setDisabled(c === true)}
180
+ >
181
+ Disabled — token issuance is blocked for this client
182
+ </Checkbox>
183
+ </Box>
184
+ </Grid.Item>
185
+ </Grid.Root>
186
+
187
+ <Box>
188
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
189
+ Redirect URIs
190
+ </Typography>
191
+ <Box paddingTop={2}>
192
+ <Textarea
193
+ name="redirectUris"
194
+ value={redirects}
195
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
196
+ setRedirects(e.target.value)
197
+ }
198
+ rows={5}
199
+ />
200
+ </Box>
201
+ <Box paddingTop={1}>
202
+ <Typography variant="pi" textColor="neutral600">
203
+ One per line — exact match, no wildcards.
204
+ </Typography>
205
+ </Box>
206
+ </Box>
207
+
208
+ <Box>
209
+ <Typography variant="pi" fontWeight="bold" textColor="neutral800">
210
+ Scopes
211
+ </Typography>
212
+ <Box paddingTop={2}>
213
+ <Flex direction="column" gap={2} alignItems="stretch">
214
+ {ALL_SCOPES.map((s) => (
215
+ <Checkbox
216
+ key={s.id}
217
+ checked={scopes.includes(s.id)}
218
+ onCheckedChange={(c: boolean | 'indeterminate') => {
219
+ if (c === true) setScopes((prev) => [...prev, s.id]);
220
+ else setScopes((prev) => prev.filter((x) => x !== s.id));
221
+ }}
222
+ >
223
+ <Typography variant="omega">
224
+ <code>{s.id}</code> — {s.label}
225
+ </Typography>
226
+ </Checkbox>
227
+ ))}
228
+ </Flex>
229
+ </Box>
230
+ </Box>
231
+
232
+ <Flex justifyContent="flex-end" gap={2}>
233
+ <Button
234
+ variant="tertiary"
235
+ onClick={() => navigate('/plugins/mcp-server/clients')}
236
+ disabled={submitting}
237
+ >
238
+ Cancel
239
+ </Button>
240
+ <Button loading={submitting} onClick={submit}>
241
+ Save changes
242
+ </Button>
243
+ </Flex>
244
+ </Flex>
245
+ </Box>
246
+ </Box>
247
+ );
248
+ }
@@ -0,0 +1,378 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ Badge,
4
+ Box,
5
+ Button,
6
+ Flex,
7
+ Grid,
8
+ IconButton,
9
+ Modal,
10
+ Table,
11
+ Tbody,
12
+ Td,
13
+ Th,
14
+ Thead,
15
+ Tr,
16
+ Typography,
17
+ } from '@strapi/design-system';
18
+ import { Eye } from '@strapi/icons';
19
+ import { useMcpApi } from '../lib/api';
20
+ import { PageHeader } from '../components/PageHeader';
21
+
22
+ interface PrincipalAdmin {
23
+ id: number;
24
+ email?: string;
25
+ firstname?: string;
26
+ lastname?: string;
27
+ username?: string;
28
+ }
29
+
30
+ interface AuditClient {
31
+ clientId: string;
32
+ clientName: string;
33
+ }
34
+
35
+ interface RecentCall {
36
+ ts: string;
37
+ principalType?: string;
38
+ principalId: string;
39
+ principalAdmin?: PrincipalAdmin | null;
40
+ sessionId?: string | null;
41
+ clientId?: string | null;
42
+ client?: AuditClient | null;
43
+ tool: string;
44
+ params?: unknown;
45
+ resultStatus: 'ok' | 'error';
46
+ errorCode?: string | null;
47
+ durationMs?: number | null;
48
+ ip?: string | null;
49
+ userAgent?: string | null;
50
+ }
51
+
52
+ interface Overview {
53
+ enabled: boolean;
54
+ resourceUrl: string;
55
+ allowedOrigins: string[];
56
+ sessions: { total: number; byPrincipal: Record<string, number> };
57
+ recentCalls: RecentCall[];
58
+ oauth: { mode: string; dcrEnabled: boolean };
59
+ }
60
+
61
+ function formatPrincipal(admin: PrincipalAdmin | null | undefined, fallbackId: string): string {
62
+ if (!admin) return fallbackId ? `#${fallbackId}` : '—';
63
+ const name = [admin.firstname, admin.lastname].filter(Boolean).join(' ').trim();
64
+ return name || admin.email || admin.username || `#${admin.id}`;
65
+ }
66
+
67
+ function Card({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
68
+ return (
69
+ <Box background="neutral0" padding={6} hasRadius shadow="tableShadow">
70
+ <Box paddingBottom={4}>
71
+ <Typography variant="delta" tag="h2">
72
+ {title}
73
+ </Typography>
74
+ </Box>
75
+ {children}
76
+ </Box>
77
+ );
78
+ }
79
+
80
+ function StatRow({ label, value }: { label: string; value: React.ReactNode }): JSX.Element {
81
+ return (
82
+ <Box>
83
+ <Typography variant="sigma" textColor="neutral600">
84
+ {label}
85
+ </Typography>
86
+ <Box paddingTop={1}>
87
+ <Typography variant="omega" fontWeight="semiBold">
88
+ {value}
89
+ </Typography>
90
+ </Box>
91
+ </Box>
92
+ );
93
+ }
94
+
95
+ function DetailRow({
96
+ label,
97
+ children,
98
+ }: {
99
+ label: string;
100
+ children: React.ReactNode;
101
+ }): JSX.Element {
102
+ return (
103
+ <Box paddingBottom={4}>
104
+ <Typography variant="sigma" textColor="neutral600">
105
+ {label}
106
+ </Typography>
107
+ <Box paddingTop={1}>{children}</Box>
108
+ </Box>
109
+ );
110
+ }
111
+
112
+ export function HomePage(): JSX.Element {
113
+ const api = useMcpApi();
114
+ const [data, setData] = useState<Overview | null>(null);
115
+ const [error, setError] = useState<string | null>(null);
116
+ const [selected, setSelected] = useState<RecentCall | null>(null);
117
+
118
+ useEffect(() => {
119
+ api
120
+ .overview()
121
+ .then(setData)
122
+ .catch((err: Error) => setError(err.message ?? String(err)));
123
+ }, []);
124
+
125
+ return (
126
+ <Box>
127
+ <PageHeader title="Overview" subtitle="Server status, sessions, and recent activity" />
128
+
129
+ {error && (
130
+ <Box background="danger100" padding={4} hasRadius marginBottom={6}>
131
+ <Typography textColor="danger700">Failed to load overview: {error}</Typography>
132
+ </Box>
133
+ )}
134
+
135
+ {!data && !error && <Typography>Loading…</Typography>}
136
+
137
+ {data && (
138
+ <Flex direction="column" gap={6} alignItems="stretch">
139
+ <Card title="Status">
140
+ <Grid.Root gap={6}>
141
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="flex-start">
142
+ <StatRow
143
+ label="State"
144
+ value={
145
+ <Badge backgroundColor={data.enabled ? 'success100' : 'danger100'}>
146
+ {data.enabled ? 'Enabled' : 'Disabled'}
147
+ </Badge>
148
+ }
149
+ />
150
+ </Grid.Item>
151
+ <Grid.Item col={5} s={6} xs={12} direction="column" alignItems="flex-start">
152
+ <StatRow
153
+ label="Resource URL"
154
+ value={data.resourceUrl || '(not configured)'}
155
+ />
156
+ </Grid.Item>
157
+ <Grid.Item col={2} s={6} xs={12} direction="column" alignItems="flex-start">
158
+ <StatRow label="OAuth mode" value={data.oauth.mode} />
159
+ </Grid.Item>
160
+ <Grid.Item col={2} s={6} xs={12} direction="column" alignItems="flex-start">
161
+ <StatRow label="DCR" value={data.oauth.dcrEnabled ? 'enabled' : 'disabled'} />
162
+ </Grid.Item>
163
+ </Grid.Root>
164
+ </Card>
165
+
166
+ <Card title="Sessions">
167
+ <Grid.Root gap={6}>
168
+ <Grid.Item col={3} s={6} xs={12} direction="column" alignItems="flex-start">
169
+ <StatRow label="Active total" value={String(data.sessions.total)} />
170
+ </Grid.Item>
171
+ </Grid.Root>
172
+ </Card>
173
+
174
+ <Card title="Recent tool calls">
175
+ <Table colCount={7} rowCount={data.recentCalls.length}>
176
+ <Thead>
177
+ <Tr>
178
+ <Th>
179
+ <Typography variant="sigma">Time</Typography>
180
+ </Th>
181
+ <Th>
182
+ <Typography variant="sigma">Principal</Typography>
183
+ </Th>
184
+ <Th>
185
+ <Typography variant="sigma">Client</Typography>
186
+ </Th>
187
+ <Th>
188
+ <Typography variant="sigma">Tool</Typography>
189
+ </Th>
190
+ <Th>
191
+ <Typography variant="sigma">Status</Typography>
192
+ </Th>
193
+ <Th>
194
+ <Typography variant="sigma">Duration</Typography>
195
+ </Th>
196
+ <Th>
197
+ <Typography variant="sigma">&nbsp;</Typography>
198
+ </Th>
199
+ </Tr>
200
+ </Thead>
201
+ <Tbody>
202
+ {data.recentCalls.map((c, i) => (
203
+ <Tr key={i}>
204
+ <Td>
205
+ <Typography variant="omega" textColor="neutral700">
206
+ {new Date(c.ts).toLocaleString()}
207
+ </Typography>
208
+ </Td>
209
+ <Td>
210
+ <Typography variant="omega">
211
+ {formatPrincipal(c.principalAdmin, c.principalId)}
212
+ </Typography>
213
+ </Td>
214
+ <Td>
215
+ <Typography variant="omega" textColor="neutral700">
216
+ {c.client?.clientName ?? '—'}
217
+ </Typography>
218
+ </Td>
219
+ <Td>
220
+ <Typography variant="omega" fontWeight="semiBold">
221
+ {c.tool}
222
+ </Typography>
223
+ </Td>
224
+ <Td>
225
+ <Badge
226
+ backgroundColor={c.resultStatus === 'ok' ? 'success100' : 'danger100'}
227
+ >
228
+ {c.resultStatus}
229
+ </Badge>
230
+ </Td>
231
+ <Td>
232
+ <Typography variant="omega" textColor="neutral700">
233
+ {c.durationMs !== undefined && c.durationMs !== null
234
+ ? `${c.durationMs}ms`
235
+ : ''}
236
+ </Typography>
237
+ </Td>
238
+ <Td>
239
+ <Flex justifyContent="flex-end">
240
+ <IconButton
241
+ label="View details"
242
+ variant="ghost"
243
+ onClick={() => setSelected(c)}
244
+ >
245
+ <Eye />
246
+ </IconButton>
247
+ </Flex>
248
+ </Td>
249
+ </Tr>
250
+ ))}
251
+ {data.recentCalls.length === 0 && (
252
+ <Tr>
253
+ <Td colSpan={7}>
254
+ <Box paddingTop={6} paddingBottom={6}>
255
+ <Typography textColor="neutral600">No activity yet.</Typography>
256
+ </Box>
257
+ </Td>
258
+ </Tr>
259
+ )}
260
+ </Tbody>
261
+ </Table>
262
+ </Card>
263
+ </Flex>
264
+ )}
265
+
266
+ <Modal.Root
267
+ open={selected !== null}
268
+ onOpenChange={(open) => !open && setSelected(null)}
269
+ >
270
+ <Modal.Content>
271
+ <Modal.Header>
272
+ <Modal.Title>Audit entry</Modal.Title>
273
+ </Modal.Header>
274
+ <Modal.Body>
275
+ {selected && (
276
+ <Box paddingTop={2}>
277
+ <DetailRow label="Time">
278
+ <Typography variant="omega">
279
+ {new Date(selected.ts).toLocaleString()}
280
+ </Typography>
281
+ </DetailRow>
282
+ <DetailRow label="Tool">
283
+ <Typography variant="omega" fontWeight="semiBold">
284
+ {selected.tool}
285
+ </Typography>
286
+ </DetailRow>
287
+ <DetailRow label="Status">
288
+ <Badge
289
+ backgroundColor={
290
+ selected.resultStatus === 'ok' ? 'success100' : 'danger100'
291
+ }
292
+ >
293
+ {selected.resultStatus}
294
+ </Badge>
295
+ {selected.errorCode && (
296
+ <Box paddingTop={2}>
297
+ <Typography variant="omega" textColor="danger700">
298
+ Error: <code>{selected.errorCode}</code>
299
+ </Typography>
300
+ </Box>
301
+ )}
302
+ </DetailRow>
303
+ <DetailRow label="Duration">
304
+ <Typography variant="omega">
305
+ {selected.durationMs !== undefined && selected.durationMs !== null
306
+ ? `${selected.durationMs}ms`
307
+ : '—'}
308
+ </Typography>
309
+ </DetailRow>
310
+ <DetailRow label="Principal">
311
+ <Typography variant="omega">
312
+ {formatPrincipal(selected.principalAdmin, selected.principalId)}
313
+ {selected.principalAdmin?.email && (
314
+ <Typography variant="pi" textColor="neutral600">
315
+ {' '}
316
+ ({selected.principalAdmin.email})
317
+ </Typography>
318
+ )}
319
+ </Typography>
320
+ </DetailRow>
321
+ <DetailRow label="Client">
322
+ <Typography variant="omega">
323
+ {selected.client?.clientName ?? '—'}
324
+ {selected.clientId && (
325
+ <Typography variant="pi" textColor="neutral600">
326
+ {' '}
327
+ (<code>{selected.clientId}</code>)
328
+ </Typography>
329
+ )}
330
+ </Typography>
331
+ </DetailRow>
332
+ <DetailRow label="Session">
333
+ <Typography variant="omega" textColor="neutral700">
334
+ <code>{selected.sessionId ?? '—'}</code>
335
+ </Typography>
336
+ </DetailRow>
337
+ <DetailRow label="IP">
338
+ <Typography variant="omega" textColor="neutral700">
339
+ {selected.ip ?? '—'}
340
+ </Typography>
341
+ </DetailRow>
342
+ {selected.userAgent && (
343
+ <DetailRow label="User-Agent">
344
+ <Typography variant="omega" textColor="neutral700">
345
+ {selected.userAgent}
346
+ </Typography>
347
+ </DetailRow>
348
+ )}
349
+ <DetailRow label="Parameters">
350
+ <Box background="neutral100" padding={3} hasRadius>
351
+ <pre
352
+ style={{
353
+ margin: 0,
354
+ whiteSpace: 'pre-wrap',
355
+ wordBreak: 'break-all',
356
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
357
+ fontSize: 13,
358
+ }}
359
+ >
360
+ {selected.params === undefined || selected.params === null
361
+ ? '(none)'
362
+ : JSON.stringify(selected.params, null, 2)}
363
+ </pre>
364
+ </Box>
365
+ </DetailRow>
366
+ </Box>
367
+ )}
368
+ </Modal.Body>
369
+ <Modal.Footer>
370
+ <Modal.Close>
371
+ <Button variant="tertiary">Close</Button>
372
+ </Modal.Close>
373
+ </Modal.Footer>
374
+ </Modal.Content>
375
+ </Modal.Root>
376
+ </Box>
377
+ );
378
+ }