portal-mojo 0.2.14 → 0.2.16

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.
@@ -53,6 +53,11 @@ of the toolkit it must render inside the app's `QueryClientProvider`
53
53
  - `sections` mixes `{key, label, icon, render, permissions?}` entries with
54
54
  `{divider: 'Label'}` side-labels.
55
55
  - `initialSection` picks the starting rail entry.
56
+ - `avatarUrl` shows an image in the header avatar (falls back to
57
+ `avatarName` initials / `icon` when absent or the image fails to load);
58
+ pass a pre-vetted URL (`safePreviewUrl`) kept in component state, never a
59
+ Query cache. `onAvatarClick` turns the avatar into a button with a camera
60
+ hover overlay (`avatarLabel` sets its label/tooltip).
56
61
 
57
62
  ## Page embedding — `onClose` is optional
58
63
 
@@ -172,6 +177,7 @@ permission behavior, not misconfig.
172
177
  | `Eyebrow` | small uppercase section heading inside a panel |
173
178
  | `FlatRow` | label + value row; `action` adds the edit pencil (`actionIcon` to override, e.g. `bi-plus-lg` for empty values) |
174
179
  | `SecurityItem` | icon + title + description row with a trailing slot (badge/button) |
180
+ | `MetricCard compact` | dense one-line KPI card for detail-view snapshot rows (value never wraps; `title` for a tooltip such as the full timestamp) |
175
181
 
176
182
  Pencil actions open `formModal` with current values as `initial`. Current-record
177
183
  operations live in the header menu or focused dialogs; content rails never
@@ -25,8 +25,13 @@ reason per batch; rows already in the target state are ineligible.
25
25
 
26
26
  The detail preserves all 14 sections: Overview, Profile, Personal, Security,
27
27
  OAuth, Groups, Sys Perms, conditional App Perms, API Keys, Devices, Logins,
28
- Audit, Notifications, and Metadata. Permission sections are labeled **System
29
- permissions** and **Product permissions** in the UI. Consuming apps install
28
+ Audit, Notifications, and Metadata. Permission sections are labeled **Sys
29
+ Perms** and **Product permissions** in the rail. Sys Perms opens with every
30
+ granted key (catalog keys neutral, app-registered accent, out-of-catalog
31
+ amber), then **Custom permissions**: out-of-catalog keys with remove (armed)
32
+ and a grant input, then the catalog tabs. Remove sends
33
+ `{permissions: {key: false}}` — django-mojo `User.set_permissions` deletes a
34
+ key on a falsy value (the mock matches). Consuming apps install
30
35
  their product tabs with `registerFormTabs(USER_APP_PERMS_TABSET, tabs)`; the
31
36
  section stays absent when none are registered. User grants can cover multiple
32
37
  groups; use a Member product permission for a single group.
@@ -77,6 +82,10 @@ per endpoint, so `UserDetail` and the fleet-wide tables share one cache.
77
82
 
78
83
  ## Avatar uploads
79
84
 
85
+ The header avatar shows the attached image (thumbnail fetched from
86
+ `/api/fileman/file/<id>` into component state) and, for admins, is a button
87
+ that opens the same manager as the kebab's "Manage avatar…".
88
+
80
89
  Manage avatar uses the shared `ImageField`: it uploads into the acting admin's
81
90
  personal File scope, then saves that completed positive numeric File id (or
82
91
  null to clear) on the target User. This matches django-mojo #1488's
@@ -116,6 +116,15 @@ new grant semantics: the backend still decides what each key authorizes.
116
116
 
117
117
  ## Composition
118
118
 
119
+ `GroupMembersPanel` and `MemberDetail` gate writes on the system-pinned
120
+ constants by default. A tenant composition that renders them for the active
121
+ group passes the group-local gates instead (`MEMBER_GROUP_SAVE_PERMISSIONS`,
122
+ `MEMBER_GROUP_INVITE_PERMISSIONS`) through `savePermissions`,
123
+ `invitePermissions` and `directoryPermissions`; the server still decides. A
124
+ product may also pass `roles` (`AdmissionRole[]`): the invite form then shows a
125
+ role select, posts that role's grants with the invitation, and records the role
126
+ label as `metadata.role`.
127
+
119
128
  `MEMBERS_ADMIN_SECTION` is root-relative and dual-mounts as `/members` in the
120
129
  standalone Admin and `/system/members` when embedded. It never consumes active
121
130
  group context. `GroupMembersPanel` is the reusable Group-detail composition;
package/docs/auth.md CHANGED
@@ -25,8 +25,8 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
25
25
 
26
26
  ## Session mechanics
27
27
 
28
- - JWT pair (`access_token`/`refresh_token`); single-flight refresh (3
29
- concurrent 401s → ONE refresh POST); pre-request gate throws a synthetic
28
+ - JWT pair (`access_token`/`refresh_token`); single-flight refresh (concurrent
29
+ gated HTTP requests and realtime reconnects → ONE refresh POST); pre-request gate throws a synthetic
30
30
  `AuthRequiredError` instead of firing doomed requests.
31
31
  - A temporary refresh failure (network, timeout or server unavailable) blocks
32
32
  the protected request and rejects with the original transport error. It
@@ -34,7 +34,15 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
34
34
  open. Only expired/unusable credentials or confirmed refresh 401/403 emit
35
35
  `unauthorized` and become `AuthRequiredError`. `refreshTokens()` still
36
36
  resolves a boolean, and concurrent gated calls share one refresh POST.
37
+ - Refresh POSTs have a 15-second deadline. A timed-out attempt releases the
38
+ shared flight so a later request or reconnect can try again. RealtimeProvider
39
+ uses this same gate before connecting and before sending authentication;
40
+ applications do not need a second refresh timer for WebSockets.
37
41
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
42
+ - An internal session revision distinguishes same-tab renewal from explicit
43
+ replacement/clearing and cross-tab storage changes. Session-bound realtime
44
+ retains this fence across unmount/remount, so a late renewal cannot undo a
45
+ terminal server denial. It is an in-memory lifecycle fence, not authorization.
38
46
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
39
47
  `'logout'`.
40
48
  - Every flow that answers with a login grant adopts it only when it carries
package/docs/realtime.md CHANGED
@@ -9,6 +9,23 @@ consumer permissions own topic subscriptions. Disabling the provider or losing
9
9
  the auth token closes the socket. A permission loss in one consumer must not
10
10
  disable this shared provider.
11
11
 
12
+ The provider binds both owned and injected clients to the shared auth session.
13
+ Before opening a socket and again when challenged, it waits for `ensureValidToken()`;
14
+ HTTP and realtime share one refresh POST. A slow or temporarily failed renewal
15
+ keeps the connection in `connecting`/`authenticating`/`backoff`, not `auth-failed`.
16
+ Consumers should preserve their current workspace during these transient states.
17
+ Confirmed refresh denial still fails closed. Token replacement invalidates old
18
+ handshakes synchronously, including during a pending refresh.
19
+ An in-flight renewal completing after an authentication failure or forced
20
+ disconnect cannot clear that suspension. An intentional replacement session
21
+ can authenticate again; ordinary token refresh cannot.
22
+
23
+ For a non-React session consumer, call `const unbind = client.bindAuthSession()`
24
+ before `client.setEnabled(true)`, and call `unbind()` on teardown. One binding
25
+ owns the client at a time; unbinding closes the connection without aborting a
26
+ renewal shared by HTTP. Standalone `connect(token)` clients keep explicit-token
27
+ behavior and are responsible for their token lifecycle.
28
+
12
29
  ## Wire contract
13
30
 
14
31
  - URL is derived from the configured API origin as exactly `/ws/realtime/`
@@ -55,7 +72,10 @@ A top-level `{type:'error'}` frame is attributed in order:
55
72
 
56
73
  1. **During authentication** — auth failure: the client closes and suspends
57
74
  until the token value changes. Surfaces through the lifecycle status only;
58
- no `error` event is dispatched.
75
+ no `error` event is dispatched. Exception: the exact direct
76
+ `message === 'Authentication timeout'` before credentials were sent on a
77
+ session-bound socket is a transport timeout and enters bounded retry.
78
+ The same error after credentials were sent remains an authentication failure.
59
79
  2. **While a subscribe/unsubscribe is pending** — that operation's denial
60
80
  (acknowledgements are uncorrelated, so denial is a generic `error`); the
61
81
  topic reports `denied`. Surfaces through the topic status only; no `error`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -52,11 +52,11 @@ export function OverviewSection({ group, openGroup, canViewCredentials, canViewA
52
52
 
53
53
  return (
54
54
  <>
55
- <div className="grid gap-3 grid-cols-2 xl:grid-cols-4" style={{ marginBottom: 14 }}>
56
- <MetricCard label="Members" value={memberCount} />
57
- <MetricCard label="Sub-groups" value={subs ? subCount : '—'} />
58
- <MetricCard label="API keys" value={canViewCredentials ? (keyPeek?.count ?? '—') : 'Unavailable'} />
59
- <MetricCard label="Last activity" value={fmt.relative(group.last_activity, '—')} />
55
+ <div className="grid gap-2 mb-4 grid-cols-[repeat(auto-fit,minmax(7.5rem,1fr))]">
56
+ <MetricCard compact label="Members" value={memberCount} />
57
+ <MetricCard compact label="Sub-groups" value={subs ? subCount : '—'} />
58
+ <MetricCard compact label="API keys" value={canViewCredentials ? (keyPeek?.count ?? '—') : 'Unavailable'} />
59
+ <MetricCard compact label="Last activity" value={fmt.relative(group.last_activity, '—')} title={group.last_activity ? fmt.datetime(group.last_activity) : undefined} />
60
60
  </div>
61
61
 
62
62
  <Eyebrow>This group</Eyebrow>
@@ -3,7 +3,8 @@ import { useQueryClient } from '@tanstack/react-query';
3
3
  import { useCan, type Params } from '../../../client/runtime';
4
4
  import { Badge, Eyebrow, SecurityItem, fmt, modal } from '../../../ui';
5
5
  import { MemberDetail, type MemberNavigationCallbacks } from './MemberDetail';
6
- import { openMemberAdmissionDialog, type AdmissionGroup, type AdmissionMode } from './member-flows';
6
+ import { openMemberAdmissionDialog, type AdmissionGroup, type AdmissionMode, type AdmissionRole } from './member-flows';
7
+ import type { PermSpec } from '../../../client/runtime';
7
8
  import {
8
9
  MEMBER_GROUP_READ_PERMISSIONS,
9
10
  MEMBER_INVITE_PERMISSIONS,
@@ -59,24 +60,36 @@ function MemberItem({ member, onOpen }: { member: MemberRow; onOpen: () => void
59
60
 
60
61
  export interface GroupMembersPanelProps extends MemberNavigationCallbacks {
61
62
  group: AdmissionGroup;
63
+ /** Global Admin gates are system-pinned; a tenant composition passes its group-local gates. */
64
+ invitePermissions?: PermSpec;
65
+ savePermissions?: PermSpec;
66
+ directoryPermissions?: PermSpec;
67
+ /** Optional fixed roles offered on invite; each sets grants and the role label. */
68
+ roles?: AdmissionRole[];
62
69
  }
63
70
 
64
- export function GroupMembersPanel({ group, onNavigateUser, onNavigateGroup }: GroupMembersPanelProps) {
71
+ export function GroupMembersPanel({
72
+ group, onNavigateUser, onNavigateGroup, roles,
73
+ invitePermissions = MEMBER_INVITE_PERMISSIONS,
74
+ savePermissions = MEMBER_SAVE_PERMISSIONS,
75
+ directoryPermissions = MEMBER_USER_DIRECTORY_PERMISSIONS,
76
+ }: GroupMembersPanelProps) {
65
77
  const qc = useQueryClient();
66
78
  const [search, setSearch] = useState('');
67
79
  const [status, setStatus] = useState<StatusFilter>('active');
68
80
  const [start, setStart] = useState(0);
69
81
  useEffect(() => setStart(0), [search, status, group.id]);
70
82
  const query = MemberModel.useList(paramsFor(group.id, search, status, start));
71
- const { can: canInvite } = useCan(MEMBER_INVITE_PERMISSIONS);
72
- const { can: canCreate } = useCan(MEMBER_SAVE_PERMISSIONS);
73
- const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
83
+ const { can: canInvite } = useCan(invitePermissions);
84
+ const { can: canCreate } = useCan(savePermissions);
85
+ const { can: canReadUsers } = useCan(directoryPermissions);
74
86
  const members = query.data?.rows ?? [];
75
87
  const count = query.data?.count ?? 0;
76
88
 
77
89
  const admit = (initialMode: AdmissionMode) => openMemberAdmissionDialog({
78
90
  group,
79
91
  initialMode,
92
+ roles,
80
93
  canInvite,
81
94
  canCreate,
82
95
  canReadUsers,
@@ -89,6 +102,7 @@ export function GroupMembersPanel({ group, onNavigateUser, onNavigateGroup }: Gr
89
102
  id={member.id}
90
103
  onClose={() => close(null)}
91
104
  readPermissions={MEMBER_GROUP_READ_PERMISSIONS}
105
+ savePermissions={savePermissions}
92
106
  onNavigateUser={onNavigateUser}
93
107
  onNavigateGroup={onNavigateGroup}
94
108
  />
@@ -33,6 +33,8 @@ export interface MemberDetailProps extends MemberNavigationCallbacks {
33
33
  onClose: () => void;
34
34
  /** Global Admin is system-pinned; fixed-group compositions pass their read gate. */
35
35
  readPermissions?: PermSpec;
36
+ /** Same for writes: a fixed-group composition passes the group-local save gate. */
37
+ savePermissions?: PermSpec;
36
38
  }
37
39
 
38
40
  function AuditTable({ memberId }: { memberId: number }) {
@@ -118,13 +120,14 @@ function GrantSummary({ member }: { member: MemberRow }) {
118
120
  export function MemberDetail({
119
121
  id, onClose, onNavigateUser, onNavigateGroup,
120
122
  readPermissions = MEMBER_READ_PERMISSIONS,
123
+ savePermissions = MEMBER_SAVE_PERMISSIONS,
121
124
  }: MemberDetailProps) {
122
125
  const qc = useQueryClient();
123
126
  const { can: canRead, isLoading: readLoading } = useCan(readPermissions);
124
127
  const query = MemberModel.useOne(canRead ? id : null);
125
128
  const save = MemberModel.useSave();
126
129
  const resend = MemberModel.useAction('resend_invite');
127
- const { can: canSave } = useCan(MEMBER_SAVE_PERMISSIONS);
130
+ const { can: canSave } = useCan(savePermissions);
128
131
  useSyncExternalStore(subscribeMemberPermissions, memberPermissionsVersion, memberPermissionsVersion);
129
132
  useSyncExternalStore(subscribeFormTabs, formTabsVersion, formTabsVersion);
130
133
 
@@ -200,13 +203,13 @@ export function MemberDetail({
200
203
  ...(onNavigateUser && member.user ? [{ label: 'View user', icon: 'bi-person', onSelect: () => onNavigateUser(member.user!.id) }] : []),
201
204
  ...(onNavigateGroup && member.group ? [{ label: 'View group', icon: 'bi-people', onSelect: () => onNavigateGroup(member.group!.id) }] : []),
202
205
  { divider: true as const },
203
- { label: 'Edit role label', icon: 'bi-person-badge', permissions: MEMBER_SAVE_PERMISSIONS, onSelect: () => { void editRole(); } },
204
- { label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions: MEMBER_SAVE_PERMISSIONS, onSelect: () => { void resendInvite(); } },
206
+ { label: 'Edit role label', icon: 'bi-person-badge', permissions: savePermissions, onSelect: () => { void editRole(); } },
207
+ { label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions: savePermissions, onSelect: () => { void resendInvite(); } },
205
208
  {
206
209
  label: member.is_active ? 'Deactivate membership' : 'Activate membership',
207
210
  icon: member.is_active ? 'bi-toggle-off' : 'bi-toggle-on',
208
211
  danger: member.is_active,
209
- permissions: MEMBER_SAVE_PERMISSIONS,
212
+ permissions: savePermissions,
210
213
  onSelect: () => { void setActive(!member.is_active); },
211
214
  },
212
215
  ];
@@ -231,10 +234,10 @@ export function MemberDetail({
231
234
  key: 'Overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
232
235
  <>
233
236
  <div className="detail-kpi-grid">
234
- <MetricCard label="Role label" value={role} />
235
- <MetricCard label="Status" value={member.is_active ? 'Active' : 'Inactive'} />
236
- <MetricCard label="Joined" value={fmt.relative(member.created)} />
237
- <MetricCard label="Effective grants" value={effectiveMemberGrants(member.permissions).length} />
237
+ <MetricCard compact label="Role label" value={role} />
238
+ <MetricCard compact label="Status" value={member.is_active ? 'Active' : 'Inactive'} />
239
+ <MetricCard compact label="Joined" value={fmt.relative(member.created)} />
240
+ <MetricCard compact label="Effective grants" value={effectiveMemberGrants(member.permissions).length} />
238
241
  </div>
239
242
  <Eyebrow>This membership</Eyebrow>
240
243
  <FlatRow label="User" action={member.user && onNavigateUser ? () => onNavigateUser(member.user!.id) : undefined} actionTitle="View user">{userLabel}</FlatRow>
@@ -5,11 +5,22 @@ import { MemberModel, type MemberRow } from './models';
5
5
 
6
6
  export interface AdmissionGroup extends Pick<Group, 'id' | 'name' | 'kind'> {}
7
7
 
8
+ /** A fixed role a product offers on invite: the grants it sets and the label it records. */
9
+ export interface AdmissionRole {
10
+ value: string;
11
+ label: string;
12
+ permissions: string[];
13
+ help?: string;
14
+ }
15
+
8
16
  /** Email-only, with no user-directory lookup or existence-dependent branch. */
9
- export async function inviteMemberByEmail(groupId: number, email: string): Promise<MemberRow | null> {
17
+ export async function inviteMemberByEmail(groupId: number, email: string, permissions?: string[]): Promise<MemberRow | null> {
10
18
  const response = await mojoCall('/api/group/member/invite', {
11
19
  method: 'POST',
12
- body: { group: groupId, email: email.trim() },
20
+ body: {
21
+ group: groupId, email: email.trim(),
22
+ ...(permissions?.length ? { permissions: Object.fromEntries(permissions.map((name) => [name, true])) } : {}),
23
+ },
13
24
  });
14
25
  return (response.data ?? null) as MemberRow | null;
15
26
  }
@@ -70,6 +81,8 @@ export interface OpenMemberAdmissionOptions {
70
81
  initialMode?: AdmissionMode;
71
82
  queryClient?: QueryClient;
72
83
  onCreated?: (member: MemberRow) => void | Promise<void>;
84
+ /** Fixed roles shown as a select on the invite form; the first is the default. */
85
+ roles?: AdmissionRole[];
73
86
  }
74
87
 
75
88
  /** Shared explicit Invite/Add chooser used by global Members and GroupDetail. */
@@ -92,16 +105,29 @@ export async function openMemberAdmissionDialog(options: OpenMemberAdmissionOpti
92
105
  try {
93
106
  let member: MemberRow | null;
94
107
  if (mode === 'invite') {
108
+ const roles = options.roles ?? [];
95
109
  const data = await formModal({
96
110
  title: `Invite a member to ${group.name}`,
97
111
  submitText: 'Send invitation',
98
112
  fields: [{
99
113
  name: 'email', type: 'email', label: 'Email', required: true,
100
114
  help: 'The same confirmation is shown whether this is a new, existing, or already-associated account.',
101
- }],
115
+ }, ...(roles.length ? [{
116
+ name: 'role', type: 'select' as const, label: 'Role', required: true,
117
+ options: roles.map((role) => ({ value: role.value, label: role.label })),
118
+ help: roles.map((role) => role.help ? `${role.label}: ${role.help}` : null).filter(Boolean).join(' '),
119
+ }] : [])],
120
+ ...(roles.length ? { initial: { role: roles[0].value } } : {}),
102
121
  });
103
122
  if (!data?.email) return null;
104
- member = await inviteMemberByEmail(group.id, String(data.email));
123
+ const role = roles.find((candidate) => candidate.value === data.role) ?? roles[0];
124
+ member = await inviteMemberByEmail(group.id, String(data.email), role?.permissions);
125
+ // The role label is display-only; the grants above are the authority. A failed
126
+ // label write leaves a correctly permissioned member, so it is not fatal.
127
+ if (member && role) {
128
+ try { member = await mojoSave<MemberRow>(MemberModel.endpoint, member.id, { metadata: { role: role.label } }); }
129
+ catch { /* label stays "Member"; grants already applied */ }
130
+ }
105
131
  toast.success('Invitation request accepted');
106
132
  } else {
107
133
  const data = await formModal({
@@ -15,6 +15,10 @@ export const MEMBER_GROUP_READ_PERMISSIONS = [
15
15
  'groups',
16
16
  ];
17
17
 
18
+ /** Group-local write gates for fixed-group compositions (a tenant admin's own grants). */
19
+ export const MEMBER_GROUP_SAVE_PERMISSIONS = ['manage_groups', 'manage_group', 'groups'];
20
+ export const MEMBER_GROUP_INVITE_PERMISSIONS = ['manage_users', 'manage_members', 'manage_group', 'manage_groups'];
21
+
18
22
  /** Global Member Admin gates. `sys.` prevents active-group grants leaking in. */
19
23
  export const MEMBER_READ_PERMISSIONS = [
20
24
  'sys.view_members',
@@ -14,14 +14,15 @@
14
14
  // action (kebab + Profile cards + Security rows share them, the source's
15
15
  // Phase 4 dedup).
16
16
  //
17
- // Header deviations forced by DetailView's prop surface (read-only this
18
- // wave — MERGE-WIRE notes filed): chips are not clickable, so the org chip's
19
- // click-through lives in the kebab ("Open organization") and as an Overview
20
- // row link; the avatar slot renders initials (no image/click slot), so the
21
- // shared ImageField avatar manager hangs off the kebab;
22
- // presence/status/locked/warning render as chips instead of a two-row aux.
23
- import { useSyncExternalStore } from 'react';
24
- import { useCan, useMe } from '../../../client/runtime';
17
+ // Header deviations forced by DetailView's prop surface: chips are not
18
+ // clickable, so the org chip's click-through lives in the kebab ("Open
19
+ // organization") and as an Overview row link; presence/status/locked/warning
20
+ // render as chips instead of a two-row aux. The avatar shows the attached
21
+ // image (preview URL fetched component-locally, never cached) and, for
22
+ // admins, clicking it opens the shared ImageField avatar manager (also in
23
+ // the kebab).
24
+ import { useEffect, useState, useSyncExternalStore } from 'react';
25
+ import { mojoCall, useCan, useMe } from '../../../client/runtime';
25
26
  import {
26
27
  DetailView,
27
28
  fmt,
@@ -31,6 +32,7 @@ import {
31
32
  type Chip,
32
33
  type DetailMenuEntry,
33
34
  } from '../../../ui';
35
+ import { safePreviewUrl } from '../../../ui/safe-url';
34
36
  import { MEMBER_READ_PERMISSIONS } from '../members';
35
37
  import {
36
38
  USER_DEVICE_PERMISSIONS,
@@ -67,6 +69,23 @@ import {
67
69
  useAdminCaller,
68
70
  } from './sections/shared';
69
71
 
72
+ /** Thumbnail URL for an attached avatar File. Like FileField, the
73
+ * capability URL stays in component state — never in a Query cache. */
74
+ function useAvatarPreview(fileId: number | null): string | null {
75
+ const [preview, setPreview] = useState<{ id: number; url: string | null } | null>(null);
76
+ useEffect(() => {
77
+ if (fileId == null) return;
78
+ const controller = new AbortController();
79
+ void mojoCall(`/api/fileman/file/${fileId}`, { signal: controller.signal }).then((envelope) => {
80
+ if (controller.signal.aborted) return;
81
+ const row = (envelope.data ?? {}) as { url?: unknown; thumbnail?: unknown };
82
+ setPreview({ id: fileId, url: safePreviewUrl(row.thumbnail) ?? safePreviewUrl(row.url) });
83
+ }, () => { /* no preview → initials */ });
84
+ return () => controller.abort();
85
+ }, [fileId]);
86
+ return fileId != null && preview?.id === fileId ? preview.url : null;
87
+ }
88
+
70
89
  export interface UserDetailProps {
71
90
  id: number;
72
91
  onClose: () => void;
@@ -99,6 +118,7 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
99
118
  onOpenGroup?: (groupId: number) => void;
100
119
  }) {
101
120
  const { data: me } = useMe();
121
+ const avatarUrl = useAvatarPreview(user.avatar?.id ?? null);
102
122
  const isSelf = me?.id === user.id;
103
123
  const canMembers = useCan(MEMBER_READ_PERMISSIONS).can;
104
124
  const canDevices = useCan(USER_DEVICE_PERMISSIONS).can;
@@ -220,6 +240,9 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
220
240
  return (
221
241
  <DetailView<UserRow>
222
242
  avatarName={user.display_name || user.username}
243
+ avatarUrl={avatarUrl}
244
+ onAvatarClick={isAdmin ? actions.openAvatarModal : undefined}
245
+ avatarLabel={user.avatar ? 'Change or remove avatar' : 'Add avatar'}
223
246
  title={user.display_name || user.username || user.email || `User #${user.id}`}
224
247
  subtitle={[user.email, user.phone_number].filter(Boolean).join(' · ')}
225
248
  chips={chips}
@@ -237,7 +260,7 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
237
260
  { divider: 'Access' },
238
261
  ...(canMembers ? [{ key: 'Groups', label: 'Groups', icon: 'bi-people', render: () => <GroupsSection user={user} onOpenGroup={onOpenGroup} /> }] : []),
239
262
  {
240
- key: 'SysPerms', label: 'System permissions', icon: 'bi-shield-check',
263
+ key: 'SysPerms', label: 'Sys Perms', icon: 'bi-shield-check',
241
264
  permissions: ADMIN,
242
265
  render: () => <SysPermsSection user={user} />,
243
266
  },
@@ -77,11 +77,14 @@ export function OverviewSection({ user, shared, onOpenGroup }: {
77
77
  return (
78
78
  <>
79
79
  <Eyebrow>Account snapshot</Eyebrow>
80
- <div className="grid gap-3 grid-cols-2 xl:grid-cols-4" style={{ marginBottom: 14 }}>
81
- <MetricCard label="Devices" value={String(deviceTotal)} />
82
- <MetricCard label="Last login" value={fmt.relative(lastLogin, '—')} />
83
- <MetricCard label="Active sessions" value={String(sessionCount)} />
84
- <MetricCard label="Groups" value={String(groupCount)} />
80
+ {/* Compact KPI strip: the modal body is narrow regardless of the
81
+ viewport, so columns auto-fit the container (not a viewport
82
+ breakpoint) and values stay on one line. */}
83
+ <div className="grid gap-2 mb-4 grid-cols-[repeat(auto-fit,minmax(7.5rem,1fr))]">
84
+ <MetricCard compact label="Devices" value={String(deviceTotal)} />
85
+ <MetricCard compact label="Last login" value={fmt.relative(lastLogin, '—')} title={lastLogin ? fmt.datetime(lastLogin) : undefined} />
86
+ <MetricCard compact label="Active sessions" value={String(sessionCount)} />
87
+ <MetricCard compact label="Groups" value={String(groupCount)} />
85
88
  </div>
86
89
 
87
90
  <Eyebrow>Identity</Eyebrow>
@@ -9,13 +9,59 @@
9
9
  // USER_APP_PERMS_TABSET via registerFormTabs — the portal's
10
10
  // User.registerPermissions equivalent (UserDetail gates the section on the
11
11
  // registry being non-empty).
12
- import { Eyebrow, FormView } from '../../../../ui';
12
+ //
13
+ // Above the catalog, "Custom permissions" lists every granted key the
14
+ // catalogs don't know (set by scripts, other apps, older catalogs) with
15
+ // add/remove. django-mojo User.set_permissions DELETES a key on a falsy
16
+ // value, so remove = `{permissions: {key: false}}`.
17
+ import { useState, useSyncExternalStore, type FormEvent } from 'react';
18
+ import { ArmedButton, Eyebrow, FormView, getFormTabs, subscribeFormTabs, toast } from '../../../../ui';
13
19
  import { UserModel, type UserRow } from '../models';
14
- import { SYSTEM_PERMISSION_TABS, USER_APP_PERMS_TABSET } from './permission-catalog';
20
+ import {
21
+ CATEGORY_PERMISSIONS,
22
+ GRANULAR_PERMISSION_TABS,
23
+ SYSTEM_PERMISSION_TABS,
24
+ USER_APP_PERMS_TABSET,
25
+ } from './permission-catalog';
26
+
27
+ const SYSTEM_KEYS = new Set([
28
+ ...CATEGORY_PERMISSIONS.map((p) => p.name),
29
+ ...GRANULAR_PERMISSION_TABS.flatMap((t) => t.permissions.map((p) => p.name)),
30
+ ]);
31
+ const PERM_KEY_RE = /^[a-z][a-z0-9_.:-]{0,63}$/;
32
+
33
+ /** Every `permissions.<key>` an app registered under the App Perms tabset. */
34
+ function useAppPermKeys(): string {
35
+ return useSyncExternalStore(
36
+ subscribeFormTabs,
37
+ () => getFormTabs(USER_APP_PERMS_TABSET)
38
+ .flatMap((t) => t.fields.map((f) => f.name))
39
+ .filter((n) => n.startsWith('permissions.'))
40
+ .map((n) => n.slice('permissions.'.length))
41
+ .join(','),
42
+ () => '',
43
+ );
44
+ }
15
45
 
16
46
  export function SysPermsSection({ user }: { user: UserRow }) {
47
+ const appKeys = new Set(useAppPermKeys().split(',').filter(Boolean));
48
+ const perms = user.permissions ?? {};
49
+ const granted = Object.keys(perms).filter((k) => Boolean(perms[k])).sort();
50
+ const custom = Object.keys(perms).filter((k) => !SYSTEM_KEYS.has(k) && !appKeys.has(k)).sort();
17
51
  return (
18
52
  <>
53
+ <Eyebrow>Granted ({granted.length})</Eyebrow>
54
+ <div className="perm-granted">
55
+ {granted.length === 0
56
+ ? <span className="dim">No system-level permissions granted.</span>
57
+ : granted.map((k) => (
58
+ <code key={k} className={`perm-key${SYSTEM_KEYS.has(k) ? '' : appKeys.has(k) ? ' perm-key-app' : ' perm-key-custom'}`}>{k}</code>
59
+ ))}
60
+ </div>
61
+
62
+ <Eyebrow>Custom permissions</Eyebrow>
63
+ <CustomPermissions user={user} keys={custom} known={(k) => SYSTEM_KEYS.has(k) || appKeys.has(k)} />
64
+
19
65
  <Eyebrow>System permissions</Eyebrow>
20
66
  <p className="dim" style={{ margin: '0 0 12px' }}>Toggles autosave as soon as you flip them.</p>
21
67
  <FormView model={UserModel} row={user} tabs={SYSTEM_PERMISSION_TABS} />
@@ -23,6 +69,86 @@ export function SysPermsSection({ user }: { user: UserRow }) {
23
69
  );
24
70
  }
25
71
 
72
+ function CustomPermissions({ user, keys, known }: { user: UserRow; keys: string[]; known: (key: string) => boolean }) {
73
+ const save = UserModel.useSave();
74
+ const [draft, setDraft] = useState('');
75
+ const [busy, setBusy] = useState<string | null>(null);
76
+ const perms = user.permissions ?? {};
77
+
78
+ const write = async (key: string, value: boolean) => {
79
+ setBusy(key);
80
+ try {
81
+ await save.mutateAsync({ id: user.id, changes: { permissions: { [key]: value } } });
82
+ toast.success(value ? `Granted ${key}` : `Removed ${key}`);
83
+ return true;
84
+ } catch (err) {
85
+ toast.error(err instanceof Error ? err.message : `Failed to update ${key}`);
86
+ return false;
87
+ } finally {
88
+ setBusy(null);
89
+ }
90
+ };
91
+
92
+ const key = draft.trim().toLowerCase();
93
+ const error = !key ? null
94
+ : !PERM_KEY_RE.test(key) ? 'Use lowercase letters, digits, and _ . : -'
95
+ : key === 'protected' ? 'Reserved key'
96
+ : known(key) ? 'Already in the catalog above — use its toggle'
97
+ : perms[key] ? 'Already granted'
98
+ : null;
99
+ const onAdd = async (e: FormEvent) => {
100
+ e.preventDefault();
101
+ if (!key || error) return;
102
+ if (await write(key, true)) setDraft('');
103
+ };
104
+
105
+ return (
106
+ <div className="perm-custom">
107
+ <p className="dim" style={{ margin: '0 0 10px' }}>Keys outside the catalog below — set by other apps, scripts, or older releases.</p>
108
+ {keys.length === 0
109
+ ? null
110
+ : (
111
+ <div className="perm-custom-list">
112
+ {keys.map((k) => {
113
+ const v = perms[k];
114
+ return (
115
+ <div key={k} className="perm-custom-row">
116
+ <code className="perm-key perm-key-custom">{k}</code>
117
+ {v !== true && <span className="dim perm-custom-value" title="Stored value">{JSON.stringify(v)}</span>}
118
+ <ArmedButton
119
+ className="btn btn-compact"
120
+ icon="bi-x-lg"
121
+ label="Remove"
122
+ armedLabel="Confirm remove"
123
+ disabled={busy !== null}
124
+ onConfirm={async () => { await write(k, false); }}
125
+ />
126
+ </div>
127
+ );
128
+ })}
129
+ </div>
130
+ )}
131
+ <form className="perm-custom-add" onSubmit={(e) => void onAdd(e)}>
132
+ <input
133
+ className="input"
134
+ placeholder="permission_key"
135
+ value={draft}
136
+ onChange={(e) => setDraft(e.target.value)}
137
+ aria-label="New permission key"
138
+ aria-invalid={error ? true : undefined}
139
+ disabled={busy !== null}
140
+ spellCheck={false}
141
+ autoCapitalize="off"
142
+ />
143
+ <button className="btn btn-primary" type="submit" disabled={!key || Boolean(error) || busy !== null}>
144
+ <i className="bi bi-plus-lg" /> Grant
145
+ </button>
146
+ </form>
147
+ {error && <p className="field-error" style={{ margin: '4px 0 0' }}>{error}</p>}
148
+ </div>
149
+ );
150
+ }
151
+
26
152
  export function AppPermsSection({ user }: { user: UserRow }) {
27
153
  return (
28
154
  <>
@@ -639,7 +639,7 @@ function AvatarModal({ user, onClose, onPendingChange }: { user: UserRow; onClos
639
639
  return (
640
640
  <div className="modal-pad">
641
641
  <h2 className="modal-title">Avatar</h2>
642
- <p className="dim">Upload an image in your personal File scope, then attach its completed numeric File id to this user.</p>
642
+ <p className="dim">Upload a new image (cropped square), or clear the current one to fall back to initials.</p>
643
643
  <ImageField
644
644
  value={relationId}
645
645
  onChange={(value) => { void attach(value); }}
@@ -346,10 +346,10 @@ export function UserDeviceDetail({ id, onClose, onOpenUser }: {
346
346
  <>
347
347
  <Eyebrow>Overview</Eyebrow>
348
348
  <div className="geoip-kpis">
349
- <MetricCard label="Locations" value={canLocations ? (locationCount ?? '—') : '—'} icon="bi-geo-alt" />
350
- <MetricCard label="Logins" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
351
- <MetricCard label="Days active" value={days ?? '—'} icon="bi-calendar-range" />
352
- <MetricCard label="Last seen" value={device.last_seen != null ? fmt.relative(device.last_seen) : 'Never'} icon="bi-clock-history" />
349
+ <MetricCard compact label="Locations" value={canLocations ? (locationCount ?? '—') : '—'} icon="bi-geo-alt" />
350
+ <MetricCard compact label="Logins" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
351
+ <MetricCard compact label="Days active" value={days ?? '—'} icon="bi-calendar-range" />
352
+ <MetricCard compact label="Last seen" value={device.last_seen != null ? fmt.relative(device.last_seen) : 'Never'} icon="bi-clock-history" />
353
353
  </div>
354
354
 
355
355
  <Eyebrow>Threat signals</Eyebrow>
@@ -538,10 +538,10 @@ export function GeoIpDossier({ id, onClose, onOpenLogin, onOpenLocation }: GeoIp
538
538
  />
539
539
 
540
540
  <div className="geoip-kpis">
541
- <MetricCard label="Risk score" value={row.risk_score != null ? `${row.risk_score} / 100` : '—'} icon="bi-speedometer2" />
542
- <MetricCard label="Incident events" value={canEvents ? (eventCount ?? '—') : '—'} icon="bi-shield-exclamation" />
543
- <MetricCard label="Logins from this IP" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
544
- <MetricCard label="Devices seen" value={canDevices ? (deviceCount ?? '—') : '—'} icon="bi-laptop" />
541
+ <MetricCard compact label="Risk score" value={row.risk_score != null ? `${row.risk_score} / 100` : '—'} icon="bi-speedometer2" />
542
+ <MetricCard compact label="Incident events" value={canEvents ? (eventCount ?? '—') : '—'} icon="bi-shield-exclamation" />
543
+ <MetricCard compact label="Logins from this IP" value={canLogins ? (loginCount ?? '—') : '—'} icon="bi-box-arrow-in-right" />
544
+ <MetricCard compact label="Devices seen" value={canDevices ? (deviceCount ?? '—') : '—'} icon="bi-laptop" />
545
545
  </div>
546
546
 
547
547
  <Eyebrow>Location &amp; network</Eyebrow>
@@ -61,13 +61,17 @@ export function sessionIsPersistent(): boolean {
61
61
  }
62
62
 
63
63
  export function setTokens(access: string, refresh?: string | null, persistent = true): void {
64
+ storeTokens(access, refresh, persistent, 'session');
65
+ }
66
+
67
+ function storeTokens(access: string, refresh: string | null | undefined, persistent: boolean, source: 'session' | 'refresh'): void {
64
68
  // Clear both storages first — a leftover token in the other storage must
65
69
  // never shadow this session (web-mojo wrote without clearing).
66
70
  clearTokens({ silent: true });
67
71
  const storage = persistent ? localStorage : sessionStorage;
68
72
  storage.setItem(ACCESS_KEY, access);
69
73
  if (refresh) storage.setItem(REFRESH_KEY, refresh);
70
- notifyAuthChanged();
74
+ notifyAuthChanged(source);
71
75
  }
72
76
 
73
77
  export function clearTokens(opts: { silent?: boolean } = {}): void {
@@ -89,8 +93,14 @@ export interface AuthSnapshot {
89
93
 
90
94
  let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
91
95
  let snapshotKey = '∅';
96
+ let sessionRevision = 0;
92
97
  const authListeners = new Set<() => void>();
93
98
 
99
+ /** @internal Same-tab replacement fence; ordinary renewal does not advance it. */
100
+ export function getAuthSessionRevision(): number {
101
+ return sessionRevision;
102
+ }
103
+
94
104
  function computeSnapshot(): void {
95
105
  const access = getAccessToken();
96
106
  const refresh = getRefreshToken();
@@ -106,7 +116,8 @@ function computeSnapshot(): void {
106
116
  };
107
117
  }
108
118
 
109
- function notifyAuthChanged(): void {
119
+ function notifyAuthChanged(source: 'session' | 'refresh' = 'session'): void {
120
+ if (source === 'session') sessionRevision += 1;
110
121
  computeSnapshot();
111
122
  for (const cb of authListeners) cb();
112
123
  }
@@ -212,10 +223,12 @@ async function doRefresh(): Promise<RefreshOutcome> {
212
223
  const superseded = (): boolean => getRefreshToken() !== refresh;
213
224
  const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
214
225
  try {
215
- const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
226
+ const body = await mojoCall(REFRESH_PATH, {
227
+ method: 'POST', body: { refresh_token: refresh }, signal: AbortSignal.timeout(15_000),
228
+ });
216
229
  if (superseded()) return { ok: sessionUsable() };
217
230
  const pkg = body.data as { access_token: string; refresh_token?: string };
218
- setTokens(pkg.access_token, pkg.refresh_token, persistent);
231
+ storeTokens(pkg.access_token, pkg.refresh_token, persistent, 'refresh');
219
232
  emitAuth('refreshed');
220
233
  return { ok: true };
221
234
  } catch (error) {
@@ -5230,6 +5230,13 @@ function saveUser(user: MockUser, body: Record<string, unknown>, caller?: MockUs
5230
5230
  const existing = target[key];
5231
5231
  target[key] = isPlainObject(value) && isPlainObject(existing) ? mergeDicts(existing, value) : value;
5232
5232
  }
5233
+ // User.set_permissions parity: a falsy value REMOVES the key (add_permission
5234
+ // / remove_permission), so permissions only ever holds granted keys.
5235
+ if (isPlainObject(fields.permissions)) {
5236
+ for (const [key, value] of Object.entries(fields.permissions)) {
5237
+ if (!value) delete user.permissions[key];
5238
+ }
5239
+ }
5233
5240
  let actionResp: Record<string, unknown> | null = null;
5234
5241
  for (const [action, value] of actionEntries) {
5235
5242
  const resp = runUserAction(user, action, value, caller);
@@ -3,7 +3,8 @@ import {
3
3
  useSyncExternalStore, type PropsWithChildren,
4
4
  } from 'react';
5
5
  import { apiOrigin } from './client';
6
- import { getAccessToken, subscribeAuth } from './auth';
6
+ import { ensureValidToken, getAccessToken, getAuthSessionRevision, getRefreshToken, subscribeAuth } from './auth';
7
+ import { AuthRequiredError } from './errors';
7
8
 
8
9
  export type RealtimeLifecycleStatus =
9
10
  | 'disabled' | 'idle' | 'connecting' | 'authenticating' | 'ready'
@@ -106,6 +107,10 @@ export class RealtimeClient {
106
107
  private readonly topicTimeoutMs: number;
107
108
  private socket: RealtimeSocket | null = null;
108
109
  private token: string | null = null;
110
+ private authBinding: { unsubscribe: () => void } | null = null;
111
+ private authSessionRevision: number | null = null;
112
+ private preparing: object | null = null;
113
+ private authSent: RealtimeSocket | null = null;
109
114
  private enabled = false;
110
115
  private generation = 0;
111
116
  private reconnectAttempt = 0;
@@ -142,11 +147,44 @@ export class RealtimeClient {
142
147
  return () => this.statusListeners.delete(listener);
143
148
  };
144
149
 
150
+ /** Bind one session owner; renewal shares HTTP's single-flight auth gate. */
151
+ bindAuthSession(): () => void {
152
+ if (this.authBinding) throw new Error('Realtime client already has an auth session owner');
153
+ const binding = { unsubscribe: () => {} };
154
+ this.authBinding = binding;
155
+ this.stop(this.suspended ?? (this.enabled ? 'idle' : 'disabled'));
156
+ // Synchronous invalidation is essential: a React effect can run after
157
+ // an old refresh/handshake has completed for the previous account.
158
+ const syncSession = () => {
159
+ const revision = getAuthSessionRevision();
160
+ const replaced = this.authSessionRevision !== revision;
161
+ this.authSessionRevision = revision;
162
+ // A renewal already in flight when the server denied/revoked this
163
+ // connection is not an intentional replacement session. The fence
164
+ // also survives a renewal completed while no binding was mounted.
165
+ if (!replaced && this.suspended) {
166
+ this.token = getAccessToken();
167
+ return;
168
+ }
169
+ this.setToken(getAccessToken());
170
+ };
171
+ binding.unsubscribe = subscribeAuth(syncSession);
172
+ syncSession();
173
+ if (this.enabled) this.open();
174
+ return () => {
175
+ if (this.authBinding !== binding) return;
176
+ this.disconnect();
177
+ binding.unsubscribe();
178
+ this.authBinding = null;
179
+ };
180
+ }
181
+
145
182
  setEnabled(enabled: boolean): void {
146
183
  if (this.enabled === enabled) return;
147
184
  this.enabled = enabled;
148
185
  if (!enabled) this.stop('disabled');
149
- else if (this.token) this.open();
186
+ else if (this.suspended) this.setLifecycle(this.suspended);
187
+ else if (this.hasCredentials()) this.open();
150
188
  else this.setLifecycle('idle');
151
189
  }
152
190
 
@@ -156,7 +194,7 @@ export class RealtimeClient {
156
194
  this.token = token;
157
195
  this.suspended = null;
158
196
  this.stop(this.enabled && token ? 'idle' : this.enabled ? 'idle' : 'disabled');
159
- if (this.enabled && token) this.open();
197
+ if (this.enabled && this.hasCredentials()) this.open();
160
198
  }
161
199
 
162
200
  connect(token: string): void {
@@ -171,6 +209,8 @@ export class RealtimeClient {
171
209
 
172
210
  destroy(): void {
173
211
  this.disconnect();
212
+ this.authBinding?.unsubscribe();
213
+ this.authBinding = null;
174
214
  this.statusListeners.clear();
175
215
  this.topicListeners.clear();
176
216
  this.eventListeners.clear();
@@ -232,14 +272,18 @@ export class RealtimeClient {
232
272
  }
233
273
 
234
274
  nudge(): void {
235
- if (!this.enabled || !this.token || this.suspended || this.socket || this.retryTimer == null) return;
275
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.retryTimer == null) return;
236
276
  if (this.clock.now() < this.extendedBackoffUntil) return;
237
277
  this.clearRetry();
238
278
  this.open();
239
279
  }
240
280
 
281
+ private hasCredentials(): boolean {
282
+ return Boolean(this.token || (this.authBinding && getRefreshToken()));
283
+ }
284
+
241
285
  private open(): void {
242
- if (!this.enabled || !this.token || this.suspended || this.socket) return;
286
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.preparing) return;
243
287
  const run = this.generation;
244
288
  // Token refresh and provider remount use this same entry point; neither
245
289
  // may bypass a server admission deadline.
@@ -248,6 +292,47 @@ export class RealtimeClient {
248
292
  return;
249
293
  }
250
294
  this.setLifecycle('connecting');
295
+ this.prepareSession(run, null, () => this.openSocket(run));
296
+ }
297
+
298
+ private prepareSession(run: number, socket: RealtimeSocket | null, ready: () => void): void {
299
+ const binding = this.authBinding;
300
+ if (!binding) { ready(); return; }
301
+ if (this.preparing) return;
302
+ const attempt = {};
303
+ this.preparing = attempt;
304
+ const current = () => this.preparing === attempt && this.authBinding === binding
305
+ && this.enabled && !this.suspended && run === this.generation && this.socket === socket;
306
+ void Promise.resolve().then(() => {
307
+ if (current()) return ensureValidToken();
308
+ }).then(() => {
309
+ if (!current()) return;
310
+ this.preparing = null;
311
+ ready();
312
+ }).catch((error: unknown) => {
313
+ if (!current()) return;
314
+ this.preparing = null;
315
+ if (error instanceof AuthRequiredError) this.suspend('auth-failed', 4003, 'Authentication failed');
316
+ else this.retryAuthentication(run, socket);
317
+ });
318
+ }
319
+
320
+ private retryAuthentication(run: number, socket: RealtimeSocket | null): void {
321
+ this.preparing = null;
322
+ this.authSent = null;
323
+ this.socket = null;
324
+ this.clearHeartbeat();
325
+ this.resetTopicConnectionState();
326
+ this.scheduleReconnect(run, 0);
327
+ socket?.close(1000, 'Session renewal pending');
328
+ }
329
+
330
+ private openSocket(run: number): void {
331
+ // A renewal may have completed while an admission cooldown was set.
332
+ if (this.clock.now() < this.extendedBackoffUntil) {
333
+ this.scheduleReconnect(run, 0);
334
+ return;
335
+ }
251
336
  let socket: RealtimeSocket;
252
337
  try {
253
338
  socket = this.socketFactory(this.url);
@@ -267,6 +352,8 @@ export class RealtimeClient {
267
352
  socket.onerror = () => { /* close is the single retry path */ };
268
353
  socket.onclose = (event) => {
269
354
  if (!this.isCurrent(run, socket)) return;
355
+ this.preparing = null;
356
+ this.authSent = null;
270
357
  this.socket = null;
271
358
  this.clearHeartbeat();
272
359
  this.resetTopicConnectionState();
@@ -283,12 +370,16 @@ export class RealtimeClient {
283
370
  if (!frame || typeof frame.type !== 'string') return;
284
371
 
285
372
  if (frame.type === 'auth_required') {
286
- if (this.lifecycle !== 'authenticating' || !this.token) return;
287
- socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
373
+ if (this.lifecycle !== 'authenticating' || !this.token || this.authSent === socket) return;
374
+ this.prepareSession(run, socket, () => {
375
+ // Latch before send: a transport may answer synchronously.
376
+ this.authSent = socket;
377
+ socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
378
+ });
288
379
  return;
289
380
  }
290
381
  if (frame.type === 'auth_success') {
291
- if (this.lifecycle !== 'authenticating') return;
382
+ if (this.lifecycle !== 'authenticating' || this.authSent !== socket) return;
292
383
  this.reconnectAttempt = 0;
293
384
  this.extendedBackoffUntil = 0;
294
385
  this.automaticUserTopic = (typeof frame.user_type === 'string' && (typeof frame.user_id === 'number' || typeof frame.user_id === 'string'))
@@ -325,6 +416,8 @@ export class RealtimeClient {
325
416
  // Fence queued frames before closing: late auth_success must not
326
417
  // clear the deadline or make this refused socket ready.
327
418
  this.extendedBackoffUntil = this.clock.now() + 60_000;
419
+ this.preparing = null;
420
+ this.authSent = null;
328
421
  this.socket = null;
329
422
  this.clearHeartbeat();
330
423
  this.resetTopicConnectionState();
@@ -335,7 +428,11 @@ export class RealtimeClient {
335
428
  }
336
429
  if (frame.type === 'error') {
337
430
  if (this.lifecycle === 'authenticating') {
338
- this.suspend('auth-failed', 4003, 'Authentication failed');
431
+ // The server's admission timer can expire while renewal is
432
+ // pending. No credential was rejected in this specific case.
433
+ if (frame.message === 'Authentication timeout' && this.authBinding && this.authSent !== socket) {
434
+ this.retryAuthentication(run, socket);
435
+ } else this.suspend('auth-failed', 4003, 'Authentication failed');
339
436
  } else if (this.topicOperation) {
340
437
  this.denyCurrentTopic();
341
438
  } else {
@@ -492,6 +589,9 @@ export class RealtimeClient {
492
589
 
493
590
  private suspend(reason: 'auth-failed' | 'forced-disconnect', code: number, message: string): void {
494
591
  this.suspended = reason;
592
+ this.preparing = null;
593
+ this.authSent = null;
594
+ this.clearRetry();
495
595
  this.setLifecycle(reason);
496
596
  const socket = this.socket;
497
597
  this.socket = null;
@@ -502,6 +602,8 @@ export class RealtimeClient {
502
602
 
503
603
  private stop(status: RealtimeLifecycleStatus): void {
504
604
  this.generation += 1;
605
+ this.preparing = null;
606
+ this.authSent = null;
505
607
  this.clearRetry();
506
608
  this.clearHeartbeat();
507
609
  this.resetTopicConnectionState();
@@ -561,8 +663,7 @@ export type RealtimeProviderProps = PropsWithChildren<{
561
663
 
562
664
  export function RealtimeProvider({ children, enabled = true, client, socketFactory, origin }: RealtimeProviderProps) {
563
665
  const owned = useMemo(() => client ?? new RealtimeClient({ socketFactory, origin }), [client, socketFactory, origin]);
564
- const token = useSyncExternalStore(subscribeAuth, getAccessToken, getAccessToken);
565
- useEffect(() => { owned.setToken(token); }, [owned, token]);
666
+ useEffect(() => owned.bindAuthSession(), [owned]);
566
667
  useEffect(() => {
567
668
  owned.setEnabled(enabled);
568
669
  return () => owned.setEnabled(false);
@@ -100,7 +100,7 @@
100
100
  .file-field-preview img{inline-size:100%;block-size:100%;object-fit:cover}
101
101
  .file-field-main{display:grid;gap:3px;min-inline-size:0}.file-field-main>strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-field-status{color:var(--mute);font-size:12px}.file-field-status.is-replacement-failed,.file-field-status.is-attach-failed{color:var(--bad)}.file-field-status.is-completed-awaiting-attach{color:var(--warn)}
102
102
  .file-field-status.is-edit-failed{color:var(--bad)}.file-field-status.is-edit-ready{color:var(--warn)}.file-field-edit-actions{display:flex;align-items:center;flex-wrap:wrap;gap:5px;margin-top:4px}
103
- .file-field-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px}.file-field-drop{min-block-size:auto;padding:7px 10px;border-style:solid}.file-field-drop span{display:flex;gap:6px;white-space:nowrap}
103
+ .file-field-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px}.file-field-actions .btn{white-space:nowrap}.file-field-drop{min-block-size:auto;padding:7px 10px;border-style:solid}.file-field-drop span{display:flex;gap:6px;white-space:nowrap}
104
104
  @media(max-width:640px){.file-field{grid-template-columns:auto minmax(0,1fr)}.file-field-actions{grid-column:1/-1;justify-content:flex-start;flex-wrap:wrap}}
105
105
 
106
106
  @media (max-width: 640px) {
@@ -187,3 +187,20 @@
187
187
  width: 72px; height: 72px; border-radius: 50%; object-fit: cover;
188
188
  box-shadow: var(--shadow);
189
189
  }
190
+
191
+ /* Sys Perms — granted summary + custom (out-of-catalog) keys. */
192
+ .perm-granted { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 18px; }
193
+ .perm-key {
194
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px;
195
+ padding: 2px 8px; border-radius: 6px; line-height: 1.6;
196
+ background: var(--surface2); color: var(--ink); border: 1px solid var(--line);
197
+ }
198
+ .perm-key-app { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
199
+ .perm-key-custom { background: var(--warn-soft); color: var(--warn); border-color: transparent; }
200
+ .perm-custom-list { display: flex; flex-direction: column; margin: 0 0 12px; border-top: 1px solid var(--line); }
201
+ .perm-custom-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
202
+ .perm-custom-row > :last-child { margin-left: auto; }
203
+ .perm-custom-value { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
204
+ .perm-custom-add { display: flex; gap: 8px; max-width: 420px; }
205
+ .perm-custom-add .input { flex: 1; min-width: 0; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
206
+ .perm-custom { margin-bottom: 18px; }
@@ -494,6 +494,17 @@ button { font: inherit; color: inherit; }
494
494
  font-size: 17px; font-weight: 750;
495
495
  }
496
496
  .detail-avatar i { font-size: 22px; }
497
+ .detail-avatar { position: relative; overflow: hidden; }
498
+ .detail-avatar > img { width: 100%; height: 100%; object-fit: cover; display: block; }
499
+ .detail-avatar-btn { border: 0; padding: 0; cursor: pointer; font-family: inherit; }
500
+ .detail-avatar-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
501
+ .detail-avatar-overlay {
502
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
503
+ background: rgb(0 0 0 / 0.45); color: #fff; opacity: 0; transition: opacity 0.12s ease;
504
+ }
505
+ .detail-avatar-overlay i { font-size: 18px; }
506
+ .detail-avatar-btn:hover .detail-avatar-overlay,
507
+ .detail-avatar-btn:focus-visible .detail-avatar-overlay { opacity: 1; }
497
508
  .detail-id { min-width: 0; }
498
509
  .detail-title { margin: 0; font-size: 19px; font-weight: 750; color: var(--ink-strong); letter-spacing: -0.015em; }
499
510
  .detail-sub { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; color: var(--mute); font-size: 13.5px; margin-top: 2px; }
@@ -629,6 +640,13 @@ button { font: inherit; color: inherit; }
629
640
  .metric-icon { color: var(--mute); font-size: 14px; }
630
641
  .metric-value { font-size: 26px; font-weight: 750; color: var(--ink-strong); letter-spacing: -0.02em; font-variant-numeric: tabular-nums; margin-top: 4px; }
631
642
  .metric-hint { font-size: 12px; color: var(--mute); margin-top: 2px; }
643
+ .metric-compact { padding: 10px 12px; border-radius: 10px; box-shadow: none; min-width: 0; }
644
+ .metric-compact .metric-label { font-size: 10px; letter-spacing: 0.08em; }
645
+ .metric-compact .metric-value {
646
+ font-size: 17px; font-weight: 700; margin-top: 2px; line-height: 1.3;
647
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
648
+ }
649
+ .metric-compact .metric-hint { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
632
650
  .detail-kpi-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; }
633
651
  .detail-kpi-grid > .metric { min-width: 0; }
634
652
  @media (max-width: 600px) { .detail-kpi-grid { grid-template-columns: minmax(0, 1fr); } }
@@ -84,6 +84,14 @@ export type DetailMenuEntry<TCtx = unknown> = DetailMenuItem<TCtx> | { divider:
84
84
 
85
85
  export interface DetailViewProps<TCtx = unknown> {
86
86
  avatarName?: string;
87
+ /** Image for the header avatar; falls back to `avatarName` initials /
88
+ * `icon` when absent or when the image fails to load. Callers pass a
89
+ * pre-vetted URL (e.g. through `safePreviewUrl`). */
90
+ avatarUrl?: string | null;
91
+ /** Makes the header avatar a button (e.g. open an avatar manager). */
92
+ onAvatarClick?: () => void;
93
+ /** Accessible label/tooltip for the avatar button. */
94
+ avatarLabel?: string;
87
95
  icon?: string;
88
96
  title: string;
89
97
  subtitle?: string;
@@ -228,13 +236,18 @@ function railBadge(value: ReactNode): ReactNode {
228
236
  // ── The component ─────────────────────────────────────────────────────
229
237
 
230
238
  export function DetailView<TCtx = unknown>({
231
- avatarName, icon, title, subtitle, chips = [], active,
239
+ avatarName, avatarUrl, onAvatarClick, avatarLabel, icon, title, subtitle, chips = [], active,
232
240
  sections, initialSection, badges, contextMenu, menuContext, onClose,
233
241
  }: DetailViewProps<TCtx>) {
234
242
  const [sectionsExpanded, setSectionsExpanded] = useState(false);
235
243
  const sectionToggleRef = useRef<HTMLButtonElement>(null);
236
244
  const can = useCanChecker();
237
245
  const warnOnce = useWarnOnce();
246
+ const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
247
+ const showImage = Boolean(avatarUrl) && avatarUrl !== failedAvatarUrl;
248
+ const avatarInner = showImage
249
+ ? <img src={avatarUrl!} alt="" referrerPolicy="no-referrer" onError={() => setFailedAvatarUrl(avatarUrl ?? null)} />
250
+ : avatarName ? initials(avatarName) : <i className={`bi ${icon ?? 'bi-file-earmark'}`} />;
238
251
 
239
252
  // One-time schema lint: duplicate keys break activation + keep-alive
240
253
  // keying; badge keys must name a section; a never-matching
@@ -305,9 +318,20 @@ export function DetailView<TCtx = unknown>({
305
318
  return (
306
319
  <div className={`detail${onClose ? ' detail-modal-body' : ''}`}>
307
320
  <header className="detail-header">
308
- <div className="detail-avatar">
309
- {avatarName ? initials(avatarName) : <i className={`bi ${icon ?? 'bi-file-earmark'}`} />}
310
- </div>
321
+ {onAvatarClick ? (
322
+ <button
323
+ type="button"
324
+ className="detail-avatar detail-avatar-btn"
325
+ onClick={onAvatarClick}
326
+ aria-label={avatarLabel ?? 'Change avatar'}
327
+ title={avatarLabel ?? 'Change avatar'}
328
+ >
329
+ {avatarInner}
330
+ <span className="detail-avatar-overlay" aria-hidden="true"><i className="bi bi-camera" /></span>
331
+ </button>
332
+ ) : (
333
+ <div className="detail-avatar">{avatarInner}</div>
334
+ )}
311
335
  <div className="detail-id">
312
336
  <h2 className="detail-title">{title}</h2>
313
337
  <div className="detail-sub">
package/src/ui/ui.tsx CHANGED
@@ -9,9 +9,18 @@ export function Badge({ tone, children }: { tone?: Tone; children: ReactNode })
9
9
  return <span className={`chip chip-${t}`}>{children === true ? 'Yes' : children === false ? 'No' : children}</span>;
10
10
  }
11
11
 
12
- export function MetricCard({ label, value, hint, icon }: { label: string; value: ReactNode; hint?: string; icon?: string }) {
12
+ export function MetricCard({ label, value, hint, icon, compact, title }: {
13
+ label: string;
14
+ value: ReactNode;
15
+ hint?: string;
16
+ icon?: string;
17
+ /** Dense single-line card for detail-view KPI rows (value never wraps). */
18
+ compact?: boolean;
19
+ /** Tooltip — e.g. the full timestamp behind a relative value. */
20
+ title?: string;
21
+ }) {
13
22
  return (
14
- <div className="metric">
23
+ <div className={compact ? 'metric metric-compact' : 'metric'} title={title}>
15
24
  <div className="metric-top">
16
25
  <span className="metric-label">{label}</span>
17
26
  {icon && <i className={`bi ${icon} metric-icon`} />}