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.
- package/docs/DetailView.md +6 -0
- package/docs/admin-identity-users.md +11 -2
- package/docs/admin-members.md +9 -0
- package/package.json +1 -1
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +5 -5
- package/src/admin/identity/members/GroupMembersPanel.tsx +19 -5
- package/src/admin/identity/members/MemberDetail.tsx +11 -8
- package/src/admin/identity/members/member-flows.tsx +30 -4
- package/src/admin/identity/members/models.ts +4 -0
- package/src/admin/identity/users/UserDetail.tsx +32 -9
- package/src/admin/identity/users/sections/OverviewSection.tsx +8 -5
- package/src/admin/identity/users/sections/PermissionsSection.tsx +128 -2
- package/src/admin/identity/users/sections/actions.tsx +1 -1
- package/src/admin/security/devices/UserDeviceDetail.tsx +4 -4
- package/src/admin/security/geoip/GeoIpDossier.tsx +4 -4
- package/src/client/mock.ts +7 -0
- package/src/styles/components/file-upload.css +1 -1
- package/src/styles/components/user-admin.css +17 -0
- package/src/styles/core.css +18 -0
- package/src/ui/DetailView.tsx +28 -4
- package/src/ui/ui.tsx +11 -2
package/docs/DetailView.md
CHANGED
|
@@ -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 **
|
|
29
|
-
|
|
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
|
package/docs/admin-members.md
CHANGED
|
@@ -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.
|
|
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-
|
|
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({
|
|
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(
|
|
72
|
-
const { can: canCreate } = useCan(
|
|
73
|
-
const { can: canReadUsers } = useCan(
|
|
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(
|
|
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:
|
|
204
|
-
{ label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions:
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
import {
|
|
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: '
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<MetricCard label="
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 & network</Eyebrow>
|
package/src/client/mock.ts
CHANGED
|
@@ -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; }
|
package/src/styles/core.css
CHANGED
|
@@ -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); } }
|
package/src/ui/DetailView.tsx
CHANGED
|
@@ -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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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=
|
|
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`} />}
|