create-astram-app 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
6
6
  "type": "module",
@@ -122,6 +122,24 @@ action })` for guarded ones. Anything unclassified is denied by design (403; wit
122
122
 
123
123
  `server/src/features/` starts empty — it's your space.
124
124
 
125
+ ## Send your own notifications
126
+
127
+ The header has a bell (personal notices) and an Announcements icon; both show unread notices,
128
+ and **View all notifications** opens the full page (`/notifications`) with search, filters,
129
+ mark unread and archive. Your features can post into the same menus:
130
+
131
+ 1. With `auth: true`, register each kind in `ShieldModule.forRoot({ notificationKinds })`:
132
+ `category: 'personal'` (bell) or `'announcement'`, a `label` users see (≤ 40 characters) and
133
+ an optional `severity` (`info` | `success` | `warning`). See the comment in
134
+ `server/src/app.module.ts`.
135
+ 2. Post with `server/src/notifications/app-notifications.service.ts` (an example to copy; add it
136
+ to your module's `providers`):
137
+ `notifyUsers(tenantId, [userId], { kind: 'app.task_assigned', title: 'Order 123 needs your approval', link: '/orders/123' })`.
138
+ Clicking the notice opens `link`; that page still checks access itself. Take `tenantId` from
139
+ the session or the record you saved, never from the request body.
140
+ 3. How long notices are kept: `NOTIFICATION_READ_RETENTION_DAYS` (default 90) and
141
+ `NOTIFICATION_MAX_RETENTION_DAYS` (default 365) in `server/.env` (see `server/_env.example`).
142
+
125
143
  ## Bootstrap your organisation
126
144
 
127
145
  `npm run bootstrap:org` (in `server/`) creates your organisation (tenants, companies,
@@ -12,7 +12,7 @@
12
12
  "test": "node scripts/run-tests.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.10.0",
15
+ "@astram/frontend-core": "^0.11.0",
16
16
  "@astram/sdk": "^0.2.1",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -41,6 +41,7 @@ import GrantsPage from './pages/cross-tenant/GrantsPage';
41
41
  import GrantForm from './pages/cross-tenant/GrantForm';
42
42
  import InboxPage from './pages/cross-tenant/InboxPage';
43
43
  import SecuritySettingsPage from './pages/security/SecuritySettingsPage';
44
+ import NotificationsPage from './pages/notifications/NotificationsPage';
44
45
 
45
46
  /** Only-when-signed-out (auth screens bounce authenticated users to the app). */
46
47
  function PublicOnly({ children }) {
@@ -164,6 +165,17 @@ export default function App() {
164
165
  </RequireAuth>
165
166
  }
166
167
  />
168
+ {/* Notifications (G-035) — every notice of the signed-in user in the active company,
169
+ reached from the bell / Announcements "View all notifications". Signed in only, no
170
+ permission key: the server binds every call to the session's own user and company. */}
171
+ <Route
172
+ path="/notifications"
173
+ element={
174
+ <RequireAuth>
175
+ <NotificationsPage />
176
+ </RequireAuth>
177
+ }
178
+ />
167
179
  {/* Account — a persistent layout with per-tab child routes (bookmarkable, back
168
180
  works). /account redirects to the Profile tab. */}
169
181
  <Route
@@ -0,0 +1,550 @@
1
+ /**
2
+ * NotificationsPage — every notice of the signed-in user in the active company (G-035,
3
+ * notification-center SPEC §5F), reached from the bell and Announcements menus ("View all
4
+ * notifications"). Any signed-in user; no permission key: the server binds every call to the
5
+ * session's user and company, so a user only ever sees and changes their own notices.
6
+ *
7
+ * Tabs Notifications (`personal`) / Announcements (`announcement`); a server-paged EntityList
8
+ * with search, Type / Status / Date filters and page sizes 10/20/50/100. Tab, filters, search,
9
+ * page and limit live in the URL (notificationList.js), so a reload or a shared link keeps the
10
+ * view. A row click marks the notice read and follows its link when app-relative. Row actions
11
+ * and the bulk bar mark read / unread and archive / unarchive; "Mark all read" clears the tab.
12
+ * After each change the list reloads and the header badges refresh.
13
+ *
14
+ * Title, body and type label are plain React text (never HTML). No raw uuids: a row's uuid is a
15
+ * React key and a call argument only; the type shows `kindLabel`, never `kind`.
16
+ */
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { useNavigate, useSearchParams } from 'react-router-dom';
19
+ import { Bell, CheckCheck, Loader2, MoreHorizontal, RotateCw } from 'lucide-react';
20
+ import { toast } from 'sonner';
21
+ import { useAuth } from '@/auth/AuthContext';
22
+ import AppShell from '@/shell/AppShell';
23
+ import { useNotificationCounts } from '@/shell/useNotificationCounts';
24
+ import { badgeText, safeAppLink, severityInfo } from '@/shell/notificationsView';
25
+ import PageHeader from '@/components/PageHeader';
26
+ import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
27
+ import { Badge } from '@/components/ui/badge';
28
+ import { Button } from '@/components/ui/button';
29
+ import { Checkbox } from '@/components/ui/checkbox';
30
+ import { DateField } from '@/components/ui/date-field';
31
+ import {
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuItem,
35
+ DropdownMenuTrigger,
36
+ } from '@/components/ui/dropdown-menu';
37
+ import {
38
+ Select,
39
+ SelectContent,
40
+ SelectItem,
41
+ SelectTrigger,
42
+ SelectValue,
43
+ } from '@/components/ui/select';
44
+ import { cn } from '@/lib/utils';
45
+ import {
46
+ BULK_ACTIONS,
47
+ BULK_MAX,
48
+ DATE_OPTIONS,
49
+ PAGE_SIZES,
50
+ STATUS_OPTIONS,
51
+ TABS,
52
+ buildListQuery,
53
+ bulkDoneText,
54
+ dateRange,
55
+ emptyText,
56
+ keepSelection,
57
+ kindOptionsFor,
58
+ pageStateToParams,
59
+ readPageState,
60
+ receivedText,
61
+ rowActions,
62
+ statusLabel,
63
+ statusVariant,
64
+ } from './notificationList';
65
+
66
+ // Radix Select items cannot carry an empty value: '' (All / Any) travels as this sentinel.
67
+ const ALL = '__all__';
68
+ const toSelect = (v) => (v ? v : ALL);
69
+ const fromSelect = (v) => (v === ALL ? '' : v);
70
+
71
+ /** The single-notice service call for each action. */
72
+ const SINGLE_CALL = {
73
+ read: 'markNotificationRead',
74
+ unread: 'markNotificationUnread',
75
+ archive: 'archiveNotification',
76
+ unarchive: 'unarchiveNotification',
77
+ };
78
+
79
+ /** One filter select (Type / Status / Date). */
80
+ function FilterSelect({ label, value, options, onChange, className }) {
81
+ return (
82
+ <Select value={toSelect(value)} onValueChange={(v) => onChange(fromSelect(v))}>
83
+ <SelectTrigger className={cn('h-9', className)} aria-label={label}>
84
+ <SelectValue />
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {options.map((o) => (
88
+ <SelectItem key={o.value || ALL} value={toSelect(o.value)}>
89
+ {o.label}
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ );
95
+ }
96
+
97
+ /** The tab strip (WAI-ARIA tabs: arrow keys move between the two tabs). */
98
+ function CategoryTabs({ tab, counts, onChange }) {
99
+ const onKeyDown = (e) => {
100
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
101
+ e.preventDefault();
102
+ const i = TABS.findIndex((t) => t.value === tab);
103
+ const next = TABS[(i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length];
104
+ onChange(next.value);
105
+ document.getElementById(`notifications-tab-${next.value}`)?.focus();
106
+ };
107
+ return (
108
+ <div role="tablist" aria-label="Notification type" className="mb-3 flex gap-1 border-b">
109
+ {TABS.map((t) => {
110
+ const active = t.value === tab;
111
+ const unread = badgeText(counts[t.value]);
112
+ return (
113
+ <button
114
+ key={t.value}
115
+ id={`notifications-tab-${t.value}`}
116
+ type="button"
117
+ role="tab"
118
+ aria-selected={active}
119
+ aria-controls="notifications-panel"
120
+ tabIndex={active ? 0 : -1}
121
+ onClick={() => onChange(t.value)}
122
+ onKeyDown={onKeyDown}
123
+ className={cn(
124
+ '-mb-px inline-flex items-center gap-2 border-b-2 px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
125
+ active
126
+ ? 'border-primary text-foreground'
127
+ : 'border-transparent text-muted-foreground hover:text-foreground',
128
+ )}
129
+ >
130
+ {t.label}
131
+ {unread && (
132
+ <Badge variant="info" className="px-1.5 py-0">
133
+ {unread}
134
+ <span className="sr-only"> unread</span>
135
+ </Badge>
136
+ )}
137
+ </button>
138
+ );
139
+ })}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ export default function NotificationsPage() {
145
+ const { tokens, notifications } = useAuth();
146
+ const counts = useNotificationCounts();
147
+ const { refresh } = counts;
148
+ const navigate = useNavigate();
149
+ const token = tokens?.accessToken ?? null;
150
+ const [searchParams, setSearchParams] = useSearchParams();
151
+ // The URL is the one source of the view; every change writes it back (replace, no history spam).
152
+ const state = useMemo(() => readPageState(searchParams), [searchParams]);
153
+
154
+ const [data, setData] = useState({ status: 'loading', rows: [], total: 0, error: '' });
155
+ const [kinds, setKinds] = useState([]);
156
+ const [selected, setSelected] = useState(() => new Set());
157
+ const [busy, setBusy] = useState(false);
158
+ // Bumped per load: only the latest answer applies (a slow older answer never overwrites it).
159
+ const loadSeq = useRef(0);
160
+
161
+ const update = useCallback(
162
+ (patch) => setSearchParams(pageStateToParams({ ...state, ...patch }), { replace: true }),
163
+ [state, setSearchParams],
164
+ );
165
+
166
+ const load = useCallback(
167
+ async (signal) => {
168
+ if (!token) return;
169
+ const seq = ++loadSeq.current;
170
+ setData((d) => ({ ...d, status: 'loading' }));
171
+ const r = await notifications.listNotifications(
172
+ token,
173
+ buildListQuery(state, Date.now()),
174
+ signal,
175
+ );
176
+ if (signal?.aborted || seq !== loadSeq.current) return;
177
+ if (r.ok) {
178
+ setData({ status: 'ready', rows: r.rows, total: r.total, error: '' });
179
+ setSelected((s) => keepSelection(s, r.rows));
180
+ } else {
181
+ setData({ status: 'error', rows: [], total: 0, error: r.error });
182
+ }
183
+ },
184
+ [notifications, token, state],
185
+ );
186
+
187
+ useEffect(() => {
188
+ const ac = new AbortController();
189
+ void load(ac.signal);
190
+ return () => ac.abort();
191
+ }, [load]);
192
+
193
+ // The Type filter's options: the kinds this user has notices of (failure ⇒ only "All types").
194
+ useEffect(() => {
195
+ if (!token) return undefined;
196
+ const ac = new AbortController();
197
+ void (async () => {
198
+ const r = await notifications.listNotificationKinds(token);
199
+ if (!ac.signal.aborted && r.ok) setKinds(r.kinds);
200
+ })();
201
+ return () => ac.abort();
202
+ }, [notifications, token]);
203
+
204
+ // Past the last page (e.g. the last row on it was archived away): step back to the last page.
205
+ useEffect(() => {
206
+ if (data.status !== 'ready' || data.rows.length > 0 || state.page <= 1 || data.total <= 0) {
207
+ return;
208
+ }
209
+ update({ page: Math.max(1, Math.ceil(data.total / state.limit)) });
210
+ }, [data, state.page, state.limit, update]);
211
+
212
+ const kindOptions = useMemo(
213
+ () => [{ value: '', label: 'All types' }, ...kindOptionsFor(kinds, state.tab)],
214
+ [kinds, state.tab],
215
+ );
216
+ const range = dateRange(state);
217
+
218
+ /** After a successful change: reload this page and the header badges. */
219
+ const afterChange = useCallback(() => {
220
+ void load();
221
+ void refresh();
222
+ }, [load, refresh]);
223
+
224
+ // One notice, one action. A failure shows the fixed error text and leaves the row as it was.
225
+ const actOne = useCallback(
226
+ async (action, row) => {
227
+ if (!token || busy) return;
228
+ setBusy(true);
229
+ const r = await notifications[SINGLE_CALL[action]](token, row.uuid);
230
+ setBusy(false);
231
+ if (!r.ok) {
232
+ toast.error(r.error);
233
+ return;
234
+ }
235
+ afterChange();
236
+ },
237
+ [notifications, token, busy, afterChange],
238
+ );
239
+
240
+ const actBulk = async (action) => {
241
+ const uuids = [...selected].slice(0, BULK_MAX);
242
+ if (!token || busy || uuids.length === 0) return;
243
+ setBusy(true);
244
+ const r = await notifications.bulkUpdateNotifications(token, action, uuids);
245
+ setBusy(false);
246
+ if (!r.ok) {
247
+ toast.error(r.error);
248
+ return;
249
+ }
250
+ toast.success(bulkDoneText(action, r.updated));
251
+ setSelected(new Set());
252
+ afterChange();
253
+ };
254
+
255
+ const markAllRead = async () => {
256
+ if (!token || busy) return;
257
+ setBusy(true);
258
+ const r = await notifications.markAllNotificationsRead(token, state.tab);
259
+ setBusy(false);
260
+ if (!r.ok) {
261
+ toast.error(r.error);
262
+ return;
263
+ }
264
+ toast.success(bulkDoneText('read', r.updated));
265
+ afterChange();
266
+ };
267
+
268
+ // Row click: mark read (if unread), then follow an app-relative link; else reload in place.
269
+ const openRow = useCallback(
270
+ async (row) => {
271
+ if (busy) return;
272
+ if (!row.readAt && token) {
273
+ const r = await notifications.markNotificationRead(token, row.uuid);
274
+ if (!r.ok) toast.error(r.error);
275
+ }
276
+ const link = safeAppLink(row.link);
277
+ void refresh();
278
+ if (link) navigate(link);
279
+ else void load();
280
+ },
281
+ [busy, token, notifications, refresh, navigate, load],
282
+ );
283
+
284
+ const pageUuids = data.rows.map((r) => r.uuid);
285
+ const selectedOnPage = pageUuids.filter((u) => selected.has(u)).length;
286
+ const allChecked =
287
+ selectedOnPage > 0 && selectedOnPage === pageUuids.length
288
+ ? true
289
+ : selectedOnPage > 0
290
+ ? 'indeterminate'
291
+ : false;
292
+
293
+ const toggleOne = (uuid, on) =>
294
+ setSelected((s) => {
295
+ const next = new Set(s);
296
+ if (on) next.add(uuid);
297
+ else next.delete(uuid);
298
+ return next;
299
+ });
300
+ const toggleAll = (on) => setSelected(on ? new Set(pageUuids) : new Set());
301
+
302
+ const columns = [
303
+ {
304
+ key: 'select',
305
+ label: (
306
+ <Checkbox
307
+ checked={allChecked}
308
+ onCheckedChange={(v) => toggleAll(v === true)}
309
+ disabled={data.rows.length === 0}
310
+ aria-label="Select all notifications on this page"
311
+ />
312
+ ),
313
+ render: (row) => (
314
+ <Checkbox
315
+ checked={selected.has(row.uuid)}
316
+ onCheckedChange={(v) => toggleOne(row.uuid, v === true)}
317
+ aria-label={`Select “${row.title}”`}
318
+ />
319
+ ),
320
+ },
321
+ {
322
+ key: 'severity',
323
+ label: <span className="sr-only">Severity</span>,
324
+ render: (row) => {
325
+ const s = severityInfo(row.severity);
326
+ return (
327
+ <span className="inline-flex items-center">
328
+ <span aria-hidden="true" className={cn('size-2 rounded-full', s.dotClass)} />
329
+ <span className="sr-only">{s.label}</span>
330
+ </span>
331
+ );
332
+ },
333
+ },
334
+ {
335
+ key: 'notification',
336
+ label: 'Notification',
337
+ render: (row) => (
338
+ <div className="min-w-0 max-w-xl text-left">
339
+ <div className={cn('text-sm', row.readAt ? 'font-normal' : 'font-semibold')}>
340
+ {row.title}
341
+ </div>
342
+ {row.body && (
343
+ <div className="mt-0.5 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
344
+ {row.body}
345
+ </div>
346
+ )}
347
+ <div className="mt-1 text-[11px] text-muted-foreground">{row.kindLabel}</div>
348
+ </div>
349
+ ),
350
+ },
351
+ {
352
+ key: 'received',
353
+ label: 'Received',
354
+ render: (row) => (
355
+ <span className="whitespace-nowrap text-sm">{receivedText(row.createdAt) || '—'}</span>
356
+ ),
357
+ },
358
+ {
359
+ key: 'status',
360
+ label: 'Status',
361
+ render: (row) => <Badge variant={statusVariant(row)}>{statusLabel(row)}</Badge>,
362
+ },
363
+ {
364
+ key: 'actions',
365
+ label: <span className="sr-only">Actions</span>,
366
+ render: (row) => (
367
+ <DropdownMenu>
368
+ <DropdownMenuTrigger asChild>
369
+ <button
370
+ type="button"
371
+ disabled={busy}
372
+ aria-label={`Actions for “${row.title}”`}
373
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
374
+ >
375
+ <MoreHorizontal className="size-4" />
376
+ </button>
377
+ </DropdownMenuTrigger>
378
+ <DropdownMenuContent align="end" className="w-40">
379
+ {rowActions(row).map((a) => (
380
+ <DropdownMenuItem key={a.action} onClick={() => void actOne(a.action, row)}>
381
+ {a.label}
382
+ </DropdownMenuItem>
383
+ ))}
384
+ </DropdownMenuContent>
385
+ </DropdownMenu>
386
+ ),
387
+ },
388
+ ];
389
+
390
+ // EntityList keys rows by `id` (never shown).
391
+ const listRows = data.rows.map((row) => ({ ...row, id: row.uuid }));
392
+
393
+ return (
394
+ <AppShell title="Notifications">
395
+ <div className="p-4">
396
+ <PageHeader
397
+ title="Notifications"
398
+ icon={Bell}
399
+ actions={
400
+ <>
401
+ <EntityListControls
402
+ search={state.search}
403
+ onSearchChange={(search) => update({ search, page: 1 })}
404
+ searchPlaceholder="Search notifications…"
405
+ />
406
+ <Button
407
+ type="button"
408
+ size="sm"
409
+ variant="outline"
410
+ className="shrink-0"
411
+ disabled={busy || data.status !== 'ready'}
412
+ onClick={() => void markAllRead()}
413
+ >
414
+ <CheckCheck className="size-4" aria-hidden="true" />
415
+ Mark all read
416
+ </Button>
417
+ </>
418
+ }
419
+ />
420
+
421
+ <CategoryTabs
422
+ tab={state.tab}
423
+ counts={counts}
424
+ onChange={(tab) => {
425
+ if (tab !== state.tab) update({ tab, kind: '', page: 1 });
426
+ }}
427
+ />
428
+
429
+ <div
430
+ id="notifications-panel"
431
+ role="tabpanel"
432
+ aria-labelledby={`notifications-tab-${state.tab}`}
433
+ >
434
+ <div className="mb-3 flex flex-wrap items-center gap-2">
435
+ <FilterSelect
436
+ label="Type"
437
+ value={state.kind}
438
+ options={kindOptions}
439
+ onChange={(kind) => update({ kind, page: 1 })}
440
+ className="w-44"
441
+ />
442
+ <FilterSelect
443
+ label="Status"
444
+ value={state.status}
445
+ options={STATUS_OPTIONS}
446
+ onChange={(status) => update({ status, page: 1 })}
447
+ className="w-32"
448
+ />
449
+ <FilterSelect
450
+ label="Date"
451
+ value={state.date}
452
+ options={DATE_OPTIONS}
453
+ onChange={(date) =>
454
+ update(date === 'custom' ? { date, page: 1 } : { date, from: '', to: '', page: 1 })
455
+ }
456
+ className="w-36"
457
+ />
458
+ {state.date === 'custom' && (
459
+ <>
460
+ <DateField
461
+ value={state.from}
462
+ max={state.to || undefined}
463
+ onChange={(e) => update({ from: e.target.value, page: 1 })}
464
+ aria-label="From date"
465
+ aria-invalid={range.invalid || undefined}
466
+ className="w-40"
467
+ />
468
+ <span className="text-sm text-muted-foreground">to</span>
469
+ <DateField
470
+ value={state.to}
471
+ min={state.from || undefined}
472
+ onChange={(e) => update({ to: e.target.value, page: 1 })}
473
+ aria-label="To date"
474
+ aria-invalid={range.invalid || undefined}
475
+ className="w-40"
476
+ />
477
+ </>
478
+ )}
479
+ {range.invalid && (
480
+ <p role="alert" className="text-xs text-destructive">
481
+ The start date is after the end date; no date filter is applied.
482
+ </p>
483
+ )}
484
+ </div>
485
+
486
+ {selected.size > 0 && (
487
+ <div
488
+ role="region"
489
+ aria-label="Selected notifications"
490
+ className="mb-3 flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-sm"
491
+ >
492
+ <span className="mr-1 font-medium">{selected.size} selected</span>
493
+ {BULK_ACTIONS.map((a) => (
494
+ <Button
495
+ key={a.action}
496
+ type="button"
497
+ size="sm"
498
+ variant="outline"
499
+ disabled={busy}
500
+ onClick={() => void actBulk(a.action)}
501
+ >
502
+ {a.label}
503
+ </Button>
504
+ ))}
505
+ <Button
506
+ type="button"
507
+ size="sm"
508
+ variant="ghost"
509
+ disabled={busy}
510
+ onClick={() => setSelected(new Set())}
511
+ >
512
+ Clear selection
513
+ </Button>
514
+ {busy && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
515
+ </div>
516
+ )}
517
+
518
+ <EntityList
519
+ columns={columns}
520
+ rows={listRows}
521
+ loading={data.status === 'loading'}
522
+ error={data.status === 'error' ? data.error : ''}
523
+ emptyLabel={emptyText(state)}
524
+ onRowClick={(row) => void openRow(row)}
525
+ />
526
+
527
+ {data.status === 'error' && (
528
+ <div className="mt-3 flex justify-end">
529
+ <Button type="button" size="sm" variant="outline" onClick={() => void load()}>
530
+ <RotateCw className="size-4" aria-hidden="true" />
531
+ Try again
532
+ </Button>
533
+ </div>
534
+ )}
535
+
536
+ <EntityListPager
537
+ total={data.total}
538
+ page={state.page}
539
+ limit={state.limit}
540
+ loading={data.status === 'loading'}
541
+ error={data.status === 'error' ? data.error : ''}
542
+ pageSizes={PAGE_SIZES}
543
+ onPageChange={(page) => update({ page })}
544
+ onLimitChange={(limit) => update({ limit, page: 1 })}
545
+ />
546
+ </div>
547
+ </div>
548
+ </AppShell>
549
+ );
550
+ }