create-astram-app 0.12.10 → 0.13.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.
@@ -96,3 +96,52 @@ export function actionHeading(action) {
96
96
  .map((w, i) => ACRONYMS.get(w) ?? (i === 0 ? w[0].toUpperCase() + w.slice(1) : w));
97
97
  return words.join(' ');
98
98
  }
99
+
100
+ /** A key's display label: the catalog label, else "<type name>: <action heading>"; never the raw key. */
101
+ function rowLabel(catalog, row) {
102
+ if (typeof row.label === 'string' && row.label.trim() !== '') return row.label.trim();
103
+ const type = typeLabelOf(catalog, row.typeKey);
104
+ const action = actionHeading(row.action);
105
+ if (type && action) return `${type}: ${action}`;
106
+ return type || action || 'Permission';
107
+ }
108
+
109
+ /**
110
+ * The working set after a role is picked in ASSIGN mode: the picked role's current grants
111
+ * (`nextLoaded`) plus the keys the user ticked in the form (`working` minus the previously picked
112
+ * role's grants, `prevLoaded`). So Save only adds to the role; a revoke needs an explicit untick,
113
+ * and switching roles drops the previous role's grants instead of copying them. Pure: returns a
114
+ * new Set, inputs (any iterables) untouched.
115
+ */
116
+ export function mergePicked(prevLoaded, nextLoaded, working) {
117
+ const prev = new Set(prevLoaded ?? []);
118
+ const out = new Set(nextLoaded ?? []);
119
+ for (const key of working ?? []) if (!prev.has(key)) out.add(key);
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * The cautions to confirm before saving (G-027 step 5): `[{ key, label, caution }]` for each of
125
+ * `addedKeys` (any iterable) whose catalog row has a non-blank `caution`, ordered by label (then
126
+ * key, for a stable order). Keys not in the catalog and blank cautions are ignored; duplicates
127
+ * collapse. `key` is for React keys / lookups only; user text uses `label` and `caution`.
128
+ */
129
+ export function cautionsFor(catalog, addedKeys) {
130
+ const rows = Array.isArray(catalog) ? catalog : [];
131
+ const byKey = new Map();
132
+ for (const row of rows) {
133
+ if (row && typeof row.key === 'string' && !byKey.has(row.key)) byKey.set(row.key, row);
134
+ }
135
+ const out = [];
136
+ const seen = new Set();
137
+ for (const key of addedKeys ?? []) {
138
+ if (seen.has(key)) continue;
139
+ seen.add(key);
140
+ const row = byKey.get(key);
141
+ if (!row || typeof row.caution !== 'string') continue;
142
+ const caution = row.caution.trim();
143
+ if (!caution) continue;
144
+ out.push({ key, label: rowLabel(rows, row), caution });
145
+ }
146
+ return out.sort((a, b) => a.label.localeCompare(b.label) || (a.key < b.key ? -1 : 1));
147
+ }
@@ -7,9 +7,11 @@ import { test, describe } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
8
  import {
9
9
  actionHeading,
10
+ cautionsFor,
10
11
  extraActionsByType,
11
12
  humanizeKey,
12
13
  matrixActions,
14
+ mergePicked,
13
15
  typeLabelOf,
14
16
  } from './accessMatrix.js';
15
17
 
@@ -200,3 +202,119 @@ describe('typeLabelOf / actionHeading (G-027 labels in the matrix)', () => {
200
202
  assert.equal(actionHeading('read'), 'Read');
201
203
  });
202
204
  });
205
+
206
+ describe('cautionsFor (G-027 step 5: confirm cautions before a grant is sent)', () => {
207
+ const withCaution = (typeKey, action, label, caution, extra = {}) => ({
208
+ ...row(typeKey, action, label),
209
+ caution,
210
+ ...extra,
211
+ });
212
+ const catalog = [
213
+ row('user', 'read', 'Users: view'),
214
+ withCaution('user', 'delete', 'Users: delete', 'Removes the account for good.'),
215
+ withCaution('role', 'update', 'Roles: edit', 'Can change what anyone may do.'),
216
+ withCaution('audit', 'read', 'Audit: view', ' '),
217
+ withCaution('job', 'run', 'Jobs: run', null),
218
+ ];
219
+
220
+ test('no added keys or no cautioned keys => empty', () => {
221
+ assert.deepEqual(cautionsFor(catalog, []), []);
222
+ assert.deepEqual(cautionsFor(catalog, new Set()), []);
223
+ assert.deepEqual(cautionsFor(catalog, ['app:user:read']), []);
224
+ assert.deepEqual(cautionsFor(catalog, undefined), []);
225
+ assert.deepEqual(cautionsFor(null, ['app:user:delete']), []);
226
+ });
227
+
228
+ test('one cautioned key => its label and caution', () => {
229
+ assert.deepEqual(cautionsFor(catalog, ['app:user:read', 'app:user:delete']), [
230
+ { key: 'app:user:delete', label: 'Users: delete', caution: 'Removes the account for good.' },
231
+ ]);
232
+ });
233
+
234
+ test('many => ordered by label, not by input order; duplicates collapse; a Set works', () => {
235
+ const res = cautionsFor(
236
+ catalog,
237
+ new Set(['app:user:delete', 'app:role:update', 'app:user:read']),
238
+ );
239
+ assert.deepEqual(
240
+ res.map((c) => c.label),
241
+ ['Roles: edit', 'Users: delete'],
242
+ );
243
+ const again = cautionsFor(catalog, ['app:role:update', 'app:user:delete', 'app:role:update']);
244
+ assert.deepEqual(again, res);
245
+ });
246
+
247
+ test('blank or missing caution ignored', () => {
248
+ assert.deepEqual(cautionsFor(catalog, ['app:audit:read', 'app:job:run']), []);
249
+ });
250
+
251
+ test('unknown key ignored', () => {
252
+ assert.deepEqual(cautionsFor(catalog, ['app:ghost:read', 'nope']), []);
253
+ });
254
+
255
+ test('caution text is trimmed; a missing label falls back to type + action, never the raw key', () => {
256
+ const c = [
257
+ { ...withCaution('company_branch', 'manage_keys', '', ' Mind this. ') },
258
+ row('company_branch', 'read', ''),
259
+ ];
260
+ const res = cautionsFor(c, ['app:company_branch:manage_keys']);
261
+ assert.deepEqual(res, [
262
+ {
263
+ key: 'app:company_branch:manage_keys',
264
+ label: 'Company branch: Manage keys',
265
+ caution: 'Mind this.',
266
+ },
267
+ ]);
268
+ assert.ok(!res[0].label.includes('app:'));
269
+ });
270
+
271
+ test('input catalog is not mutated', () => {
272
+ const snapshot = JSON.stringify(catalog);
273
+ cautionsFor(catalog, ['app:user:delete', 'app:role:update']);
274
+ assert.equal(JSON.stringify(catalog), snapshot);
275
+ });
276
+ });
277
+
278
+ describe('mergePicked (assign mode: picking a role keeps its grants)', () => {
279
+ const sorted = (s) => [...s].sort();
280
+
281
+ test('first pick: the role grants plus the pre-ticked key', () => {
282
+ const res = mergePicked(new Set(), new Set(['app:user:read', 'app:role:read']), [
283
+ 'app:user:invite',
284
+ ]);
285
+ assert.deepEqual(sorted(res), ['app:role:read', 'app:user:invite', 'app:user:read']);
286
+ });
287
+
288
+ test('first pick with nothing ticked: exactly the role grants (Save sends nothing)', () => {
289
+ const grants = ['app:user:read', 'app:*'];
290
+ assert.deepEqual(sorted(mergePicked([], grants, [])), sorted(grants));
291
+ });
292
+
293
+ test('a key the role already holds is not duplicated', () => {
294
+ const res = mergePicked([], ['app:user:read'], ['app:user:read']);
295
+ assert.deepEqual(sorted(res), ['app:user:read']);
296
+ });
297
+
298
+ test("switching roles drops the previous role's grants, keeps the user's ticks", () => {
299
+ const roleA = ['app:user:read', 'app:user:delete'];
300
+ const working = ['app:user:read', 'app:user:delete', 'app:job:run'];
301
+ const res = mergePicked(roleA, ['app:role:read'], working);
302
+ assert.deepEqual(sorted(res), ['app:job:run', 'app:role:read']);
303
+ });
304
+
305
+ test('clearing the role keeps only the user ticks', () => {
306
+ const res = mergePicked(['app:user:read'], [], ['app:user:read', 'app:job:run']);
307
+ assert.deepEqual(sorted(res), ['app:job:run']);
308
+ });
309
+
310
+ test('inputs are not mutated; null-safe', () => {
311
+ const prev = new Set(['a']);
312
+ const next = new Set(['b']);
313
+ const working = new Set(['a', 'c']);
314
+ mergePicked(prev, next, working);
315
+ assert.deepEqual(sorted(prev), ['a']);
316
+ assert.deepEqual(sorted(next), ['b']);
317
+ assert.deepEqual(sorted(working), ['a', 'c']);
318
+ assert.deepEqual(sorted(mergePicked(null, undefined, null)), []);
319
+ });
320
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * AnnouncementsMenu — the header Announcements icon (G-027 step 5), shown to every signed-in
3
+ * user. Badge = unread `announcement` notices + open admin tasks (useNotificationCounts).
4
+ *
5
+ * On open: for holders of `identity:role:update`, the open admin tasks (a new permission no role
6
+ * in a company holds yet): "Grant…" opens `/access/add?key=` with the key ticked; "Reviewed, not
7
+ * needed" confirms (naming the permission and company) then dismisses. Below, the announcement
8
+ * notices with the same read behaviour as the bell. The `can` gate is cosmetic: the server
9
+ * decides, and a 403 on the task list hides the section. Labels and company names only — the
10
+ * task's company id and key are used in calls and URLs, never shown.
11
+ */
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { useNavigate } from 'react-router-dom';
14
+ import { Megaphone, TriangleAlert } from 'lucide-react';
15
+ import { toast } from 'sonner';
16
+ import { useAuth, useCan } from '@/auth/AuthContext';
17
+ import ConfirmDialog from '@/components/ConfirmDialog';
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ } from '@/components/ui/dropdown-menu';
25
+ import { announcementsBadgeCount, taskSince, taskTitle } from './notificationsView';
26
+ import { TASKS_PERMISSION, useNotificationCounts } from './useNotificationCounts';
27
+ import { HeaderMenuTrigger, MenuError, MenuLoading, NoticeSection } from './NotificationsMenu';
28
+
29
+ const TASK_LIMIT = 20;
30
+
31
+ /** The open admin tasks; loads on mount (= menu open). Hidden on a 403. */
32
+ function TasksSection({ onDismiss }) {
33
+ const { tokens, identity } = useAuth();
34
+ const navigate = useNavigate();
35
+ const token = tokens?.accessToken ?? null;
36
+ const [view, setView] = useState({ status: 'loading', rows: [], total: 0, error: '' });
37
+ const alive = useRef(true);
38
+ // Bumped per load (mount, retry): only the latest answer applies, never an older, slower one.
39
+ const loadSeq = useRef(0);
40
+
41
+ useEffect(() => {
42
+ alive.current = true;
43
+ return () => {
44
+ alive.current = false;
45
+ };
46
+ }, []);
47
+
48
+ const load = useCallback(async () => {
49
+ if (!token) return;
50
+ const seq = ++loadSeq.current;
51
+ const r = await identity.listPermissionAnnouncements(token, {
52
+ status: 'open',
53
+ limit: TASK_LIMIT,
54
+ });
55
+ if (!alive.current || seq !== loadSeq.current) return;
56
+ if (r.ok) setView({ status: 'ready', rows: r.rows, total: r.total, error: '' });
57
+ else if (r.status === 403) setView((v) => ({ ...v, status: 'forbidden' }));
58
+ else setView((v) => ({ ...v, status: 'error', error: r.error }));
59
+ }, [identity, token]);
60
+
61
+ useEffect(() => {
62
+ void load();
63
+ }, [load]);
64
+
65
+ if (view.status === 'forbidden') return null;
66
+
67
+ const retry = () => {
68
+ setView((v) => ({ ...v, status: 'loading' }));
69
+ void load();
70
+ };
71
+
72
+ return (
73
+ <>
74
+ <section aria-label="Permissions to review">
75
+ <div className="px-2 pt-1.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
76
+ Permissions to review
77
+ </div>
78
+ {view.status === 'loading' && <MenuLoading />}
79
+ {view.status === 'error' && <MenuError text={view.error} onRetry={retry} />}
80
+ {view.status === 'ready' && view.rows.length === 0 && (
81
+ <p className="px-2 py-4 text-center text-sm text-muted-foreground">
82
+ No permissions to review.
83
+ </p>
84
+ )}
85
+ {view.status === 'ready' &&
86
+ view.rows.map((row) => {
87
+ const since = taskSince(row);
88
+ return (
89
+ <div key={`${row.companyUuid}|${row.key}`} className="rounded-sm px-2 py-2">
90
+ <div className="text-sm font-semibold">{taskTitle(row)}</div>
91
+ <div className="text-xs text-muted-foreground">{row.companyName}</div>
92
+ {row.caution && (
93
+ <p className="mt-1 flex items-start gap-1.5 text-xs text-warn">
94
+ <TriangleAlert className="mt-px size-3.5 shrink-0" aria-hidden="true" />
95
+ <span>
96
+ <span className="sr-only">Caution: </span>
97
+ {row.caution}
98
+ </span>
99
+ </p>
100
+ )}
101
+ {since && <div className="mt-0.5 text-[11px] text-muted-foreground">{since}</div>}
102
+ <div className="mt-1.5 flex flex-wrap gap-1">
103
+ <DropdownMenuItem
104
+ aria-label={`Grant ${row.label} in ${row.companyName}`}
105
+ onSelect={() => navigate(`/access/add?key=${encodeURIComponent(row.key)}`)}
106
+ className="h-7 px-2 text-xs font-medium text-primary focus:text-primary"
107
+ >
108
+ Grant…
109
+ </DropdownMenuItem>
110
+ <DropdownMenuItem
111
+ aria-label={`Reviewed, not needed: ${row.label} in ${row.companyName}`}
112
+ onSelect={() => onDismiss(row)}
113
+ className="h-7 px-2 text-xs"
114
+ >
115
+ Reviewed, not needed
116
+ </DropdownMenuItem>
117
+ </div>
118
+ </div>
119
+ );
120
+ })}
121
+ {view.status === 'ready' && view.total > view.rows.length && (
122
+ <p className="px-2 pb-1 text-[11px] text-muted-foreground">
123
+ Showing {view.rows.length} of {view.total}.
124
+ </p>
125
+ )}
126
+ </section>
127
+ <DropdownMenuSeparator />
128
+ </>
129
+ );
130
+ }
131
+
132
+ export default function AnnouncementsMenu() {
133
+ const { tokens, identity } = useAuth();
134
+ const canTasks = useCan(TASKS_PERMISSION);
135
+ const counts = useNotificationCounts();
136
+ const { refresh } = counts;
137
+ const [target, setTarget] = useState(null); // the task row awaiting confirmation
138
+ const [busy, setBusy] = useState(false);
139
+
140
+ // The API call runs only from the confirm handler. The task list reloads on the next open
141
+ // (the menu closed when the action was chosen); the badges refresh at once.
142
+ const dismiss = async () => {
143
+ const row = target;
144
+ const token = tokens?.accessToken;
145
+ if (!row || !token) return;
146
+ setBusy(true);
147
+ const r = await identity.dismissPermissionAnnouncement(token, row.companyUuid, row.key);
148
+ setBusy(false);
149
+ setTarget(null);
150
+ if (r.ok) toast.success(`“${row.label}” marked as reviewed in ${row.companyName}.`);
151
+ else toast.error(r.error); // fixed text: 404 no longer available, 409 already granted
152
+ void refresh();
153
+ };
154
+
155
+ return (
156
+ <>
157
+ <DropdownMenu>
158
+ <HeaderMenuTrigger
159
+ icon={Megaphone}
160
+ label="Announcements"
161
+ count={announcementsBadgeCount(counts)}
162
+ />
163
+ <DropdownMenuContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
164
+ <DropdownMenuLabel>Announcements</DropdownMenuLabel>
165
+ <DropdownMenuSeparator />
166
+ {canTasks && <TasksSection onDismiss={setTarget} />}
167
+ <NoticeSection
168
+ category="announcement"
169
+ heading={canTasks ? 'News' : undefined}
170
+ emptyText="No announcements."
171
+ />
172
+ </DropdownMenuContent>
173
+ </DropdownMenu>
174
+
175
+ <ConfirmDialog
176
+ open={!!target}
177
+ title={target ? `Mark “${target.label}” as reviewed?` : 'Mark as reviewed?'}
178
+ message={
179
+ target
180
+ ? `No role in ${target.companyName} is given this permission now. The task leaves the list; you can still grant it later from Access.`
181
+ : undefined
182
+ }
183
+ confirmLabel="Reviewed, not needed"
184
+ busy={busy}
185
+ onConfirm={dismiss}
186
+ onCancel={() => setTarget(null)}
187
+ />
188
+ </>
189
+ );
190
+ }
@@ -6,7 +6,7 @@
6
6
  import { useCallback, useEffect, useState } from 'react';
7
7
  import { useLocation, useNavigate } from 'react-router-dom';
8
8
  import { saveReturnPath } from '@/platform/returnPathStore';
9
- import { Bell, HelpCircle, LogOut, Menu, PanelLeft, Search, Settings, X } from 'lucide-react';
9
+ import { HelpCircle, LogOut, Menu, PanelLeft, Search, Settings, X } from 'lucide-react';
10
10
  import { useAuth } from '@/auth/AuthContext';
11
11
  import { Button } from '@/components/ui/button';
12
12
  import { Input } from '@/components/ui/input';
@@ -23,6 +23,8 @@ import {
23
23
  import ThemeToggle from '@/theme/ThemeToggle';
24
24
  import Sidebar from './Sidebar';
25
25
  import GraceBanner from './GraceBanner';
26
+ import NotificationsMenu from './NotificationsMenu';
27
+ import AnnouncementsMenu from './AnnouncementsMenu';
26
28
 
27
29
  /** A header icon-action button with a tooltip (ffs pattern). */
28
30
  function IconAction({ icon: Icon, label, dot }) {
@@ -174,8 +176,9 @@ export default function AppShell({ children, title: _title, section: _section })
174
176
 
175
177
  <div className="flex-1" />
176
178
 
177
- {/* Icon actions */}
178
- <IconAction icon={Bell} label="Notifications" dot />
179
+ {/* Icon actions: personal notices (bell) and announcements + admin tasks (G-027). */}
180
+ <NotificationsMenu />
181
+ <AnnouncementsMenu />
179
182
  <IconAction icon={Settings} label="Settings" />
180
183
  <IconAction icon={HelpCircle} label="Help" />
181
184
 
@@ -0,0 +1,263 @@
1
+ /**
2
+ * NotificationsMenu — the header bell (G-027 step 5): the user's PERSONAL notices (changes to
3
+ * their own access). Badge = unread personal count (useNotificationCounts). The list loads when
4
+ * the menu opens; opening marks nothing read. Clicking a notice marks it read and follows its
5
+ * link only when app-relative; "Mark all read" clears the category. Text only, never HTML.
6
+ *
7
+ * Also exports the pieces AnnouncementsMenu reuses: `HeaderMenuTrigger` (the header icon button
8
+ * with tooltip and count badge) and `NoticeSection` (one category's notices).
9
+ */
10
+ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
11
+ import { useNavigate } from 'react-router-dom';
12
+ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
13
+ import { Bell, CheckCheck, Loader2, RotateCw } from 'lucide-react';
14
+ import { toast } from 'sonner';
15
+ import { useAuth } from '@/auth/AuthContext';
16
+ import { cn } from '@/lib/utils';
17
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuLabel,
23
+ DropdownMenuSeparator,
24
+ } from '@/components/ui/dropdown-menu';
25
+ import { badgeLabel, badgeText, relativeTime, safeAppLink } from './notificationsView';
26
+ import { useNotificationCounts } from './useNotificationCounts';
27
+
28
+ /**
29
+ * The header icon button itself. Forwards its ref and any props Radix passes through `asChild`
30
+ * (handlers, `data-state`, aria attributes) to the `<button>`.
31
+ */
32
+ const HeaderIconButton = forwardRef(function HeaderIconButton(
33
+ { icon: Icon, label, count, className, ...props },
34
+ ref,
35
+ ) {
36
+ const text = badgeText(count);
37
+ return (
38
+ <button
39
+ ref={ref}
40
+ type="button"
41
+ aria-label={badgeLabel(label, count)}
42
+ {...props}
43
+ className={cn(
44
+ 'relative flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-secondary data-[state=open]:text-foreground',
45
+ className,
46
+ )}
47
+ >
48
+ <Icon className="size-[18px]" aria-hidden="true" />
49
+ {text && (
50
+ <span
51
+ aria-hidden="true"
52
+ className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-white ring-2 ring-background"
53
+ >
54
+ {text}
55
+ </span>
56
+ )}
57
+ </button>
58
+ );
59
+ });
60
+
61
+ /**
62
+ * The header icon button (AppShell `IconAction` styling) that opens a dropdown menu, with a
63
+ * tooltip. Both `asChild` triggers need a child that takes a ref: the tooltip's child is the
64
+ * Radix dropdown trigger primitive (a forwardRef component; the `ui/dropdown-menu` wrapper is a
65
+ * plain function component, so a ref given to it is dropped with a React warning), and that
66
+ * primitive's child is the forwardRef button above. The ref and extra props are forwarded too.
67
+ */
68
+ export const HeaderMenuTrigger = forwardRef(function HeaderMenuTrigger(
69
+ { icon, label, count, ...props },
70
+ ref,
71
+ ) {
72
+ return (
73
+ <Tooltip>
74
+ <TooltipTrigger asChild>
75
+ <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" asChild>
76
+ <HeaderIconButton ref={ref} icon={icon} label={label} count={count} {...props} />
77
+ </DropdownMenuPrimitive.Trigger>
78
+ </TooltipTrigger>
79
+ <TooltipContent>{label}</TooltipContent>
80
+ </Tooltip>
81
+ );
82
+ });
83
+
84
+ /** A loading line inside a menu section. */
85
+ export function MenuLoading() {
86
+ return (
87
+ <div role="status" className="flex items-center gap-2 px-2 py-4 text-sm text-muted-foreground">
88
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
89
+ Loading…
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** A fixed error line plus a "Try again" item (keeps the menu open). */
95
+ export function MenuError({ text, onRetry }) {
96
+ return (
97
+ <>
98
+ <p role="alert" className="px-2 py-2 text-sm text-destructive">
99
+ {text}
100
+ </p>
101
+ <DropdownMenuItem
102
+ onSelect={(e) => {
103
+ e.preventDefault();
104
+ onRetry();
105
+ }}
106
+ >
107
+ <RotateCw aria-hidden="true" /> Try again
108
+ </DropdownMenuItem>
109
+ </>
110
+ );
111
+ }
112
+
113
+ const LIST_LIMIT = 20;
114
+
115
+ /**
116
+ * One category's latest notices. Mounted only while the menu is open (Radix unmounts closed
117
+ * content), so it loads on open. `heading` (optional) labels the section.
118
+ */
119
+ export function NoticeSection({ category, heading, emptyText }) {
120
+ const { tokens, notifications } = useAuth();
121
+ const counts = useNotificationCounts();
122
+ const { refresh } = counts;
123
+ const navigate = useNavigate();
124
+ const token = tokens?.accessToken ?? null;
125
+ const [view, setView] = useState({ status: 'loading', rows: [], error: '', now: 0 });
126
+ const [busyAll, setBusyAll] = useState(false);
127
+ const alive = useRef(true);
128
+ // Bumped per list load and when "Mark all read" starts, so only the latest load applies: a
129
+ // slow list answer from before the mark never overwrites the list loaded after it.
130
+ const loadSeq = useRef(0);
131
+
132
+ useEffect(() => {
133
+ alive.current = true;
134
+ return () => {
135
+ alive.current = false;
136
+ };
137
+ }, []);
138
+
139
+ const load = useCallback(async () => {
140
+ if (!token) return;
141
+ const seq = ++loadSeq.current;
142
+ const r = await notifications.listNotifications(token, { category, limit: LIST_LIMIT });
143
+ if (!alive.current || seq !== loadSeq.current) return;
144
+ if (r.ok) setView({ status: 'ready', rows: r.rows, error: '', now: Date.now() });
145
+ else setView((v) => ({ ...v, status: 'error', error: r.error }));
146
+ }, [notifications, token, category]);
147
+
148
+ useEffect(() => {
149
+ void load();
150
+ }, [load]);
151
+
152
+ const retry = () => {
153
+ setView((v) => ({ ...v, status: 'loading' }));
154
+ void load();
155
+ };
156
+
157
+ // Click: mark read (unless already read), follow an app-relative link, refresh the badges.
158
+ // A failed mark shows its fixed error text and leaves the notice unread (the next list load
159
+ // and the badge show the server's state); the safe link is still followed.
160
+ const openNotice = async (row) => {
161
+ if (!row.readAt && token) {
162
+ const r = await notifications.markNotificationRead(token, row.uuid);
163
+ if (!r.ok) toast.error(r.error);
164
+ }
165
+ const link = safeAppLink(row.link);
166
+ if (link) navigate(link);
167
+ void refresh();
168
+ };
169
+
170
+ const markAll = async () => {
171
+ if (!token || busyAll) return;
172
+ setBusyAll(true);
173
+ loadSeq.current += 1; // drop any list answer still in flight from before the mark
174
+ await notifications.markAllNotificationsRead(token, category);
175
+ if (alive.current) {
176
+ setBusyAll(false);
177
+ await load();
178
+ }
179
+ void refresh();
180
+ };
181
+
182
+ const unreadInList = view.rows.some((row) => !row.readAt);
183
+ const unreadCount = category === 'personal' ? counts.personal : counts.announcement;
184
+
185
+ return (
186
+ <section aria-label={heading || undefined}>
187
+ {heading && (
188
+ <div className="px-2 pt-1.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
189
+ {heading}
190
+ </div>
191
+ )}
192
+ {view.status === 'loading' && <MenuLoading />}
193
+ {view.status === 'error' && <MenuError text={view.error} onRetry={retry} />}
194
+ {view.status === 'ready' && view.rows.length === 0 && (
195
+ <p className="px-2 py-5 text-center text-sm text-muted-foreground">{emptyText}</p>
196
+ )}
197
+ {view.status === 'ready' &&
198
+ view.rows.map((row) => {
199
+ const unread = !row.readAt;
200
+ return (
201
+ <DropdownMenuItem
202
+ key={row.uuid}
203
+ onSelect={() => void openNotice(row)}
204
+ className="items-start gap-2.5 py-2"
205
+ >
206
+ <span
207
+ aria-hidden="true"
208
+ className={cn(
209
+ 'mt-1.5 size-2 shrink-0 rounded-full',
210
+ unread ? 'bg-primary' : 'bg-transparent',
211
+ )}
212
+ />
213
+ <span className="min-w-0 flex-1">
214
+ <span className={cn('block text-sm', unread ? 'font-semibold' : 'font-normal')}>
215
+ {unread && <span className="sr-only">Unread: </span>}
216
+ {row.title}
217
+ </span>
218
+ {row.body && (
219
+ <span className="mt-0.5 line-clamp-3 block whitespace-pre-line text-xs text-muted-foreground">
220
+ {row.body}
221
+ </span>
222
+ )}
223
+ <span className="mt-0.5 block text-[11px] text-muted-foreground">
224
+ {relativeTime(row.createdAt, view.now)}
225
+ </span>
226
+ </span>
227
+ </DropdownMenuItem>
228
+ );
229
+ })}
230
+ {view.status === 'ready' && (unreadInList || unreadCount > 0) && (
231
+ <DropdownMenuItem
232
+ disabled={busyAll}
233
+ onSelect={(e) => {
234
+ e.preventDefault(); // keep the menu open to show the result
235
+ void markAll();
236
+ }}
237
+ className="justify-center text-xs font-medium"
238
+ >
239
+ {busyAll ? (
240
+ <Loader2 className="animate-spin" aria-hidden="true" />
241
+ ) : (
242
+ <CheckCheck aria-hidden="true" />
243
+ )}
244
+ Mark all read
245
+ </DropdownMenuItem>
246
+ )}
247
+ </section>
248
+ );
249
+ }
250
+
251
+ export default function NotificationsMenu() {
252
+ const { personal } = useNotificationCounts();
253
+ return (
254
+ <DropdownMenu>
255
+ <HeaderMenuTrigger icon={Bell} label="Notifications" count={personal} />
256
+ <DropdownMenuContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
257
+ <DropdownMenuLabel>Notifications</DropdownMenuLabel>
258
+ <DropdownMenuSeparator />
259
+ <NoticeSection category="personal" emptyText="You have no notifications." />
260
+ </DropdownMenuContent>
261
+ </DropdownMenu>
262
+ );
263
+ }