portal-mojo 0.2.15 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.15",
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>
@@ -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);
@@ -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`} />}