portal-mojo 0.2.5 → 0.2.6
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/admin-identity-directories-sources.json +130 -0
- package/docs/admin-identity-directories.md +60 -0
- package/docs/admin-identity-users.md +6 -1
- package/docs/admin-members.md +16 -0
- package/docs/admin-network-security.md +1 -1
- package/docs/menus-and-access.md +6 -2
- package/package.json +1 -1
- package/src/admin/core/index.ts +4 -4
- package/src/admin/domains/identity.ts +19 -1
- package/src/admin/identity/groups/GroupDetail.tsx +287 -0
- package/src/admin/identity/groups/GroupsPage.tsx +125 -0
- package/src/admin/identity/groups/group-permissions.ts +5 -0
- package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
- package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
- package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
- package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
- package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
- package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
- package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
- package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
- package/src/admin/identity/groups/group-sections/models.ts +110 -0
- package/src/admin/identity/groups/group-sections/shared.ts +21 -0
- package/src/admin/identity/groups/index.ts +6 -0
- package/src/admin/identity/groups/models.ts +70 -0
- package/src/admin/identity/index.ts +1 -0
- package/src/admin/identity/members/MemberDetail.tsx +32 -3
- package/src/admin/identity/members/models.ts +3 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
- package/src/admin/identity/users/UserDetail.tsx +2 -2
- package/src/admin/identity/users/index.ts +2 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
- package/src/admin/registry.ts +3 -1
- package/src/ui/SidebarNav.tsx +2 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"package": "portal-mojo",
|
|
3
|
+
"package_version": "0.2.5",
|
|
4
|
+
"source_repository": "https://github.com/NativeMojo/portal-mojo",
|
|
5
|
+
"source_tag": "v0.2.5",
|
|
6
|
+
"source_commit": "93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"license_file": "packages/portal-mojo/LICENSE",
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"source_path": "apps/portal/src/group-permissions.ts",
|
|
12
|
+
"source_sha256": "6237ad45328ecb9637a1a07d8954dd27cd019e22fa32942f5e7d05a76037fb5c",
|
|
13
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-permissions.ts",
|
|
14
|
+
"package_sha256": "b909f5a5cf029b8b910a70e6817fea8abd9ded6797af14a16170b2695c0c1699",
|
|
15
|
+
"adaptation": "Moves system-pinned group permission clauses into the canonical package identity surface."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"source_path": "apps/portal/src/models.ts",
|
|
19
|
+
"source_sha256": "0b98a906a774e572f9109703fe22444c3a2e190ca5e02c3a93dfbd02cc3fcb84",
|
|
20
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/models.ts",
|
|
21
|
+
"package_sha256": "6ac12a64d00e409874bc1b13d7941cf54c38a715af5ebcfddd2b2e9257356759",
|
|
22
|
+
"adaptation": "Extracts the Group model/type and group kind options; shared User, Member and Log models remain owned by their package domains."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"source_path": "apps/portal/src/pages/ApiKeysPage.tsx",
|
|
26
|
+
"source_sha256": "3d338cf77c3110b72a9361565c1cced10a7fccc52c486e3dafda119d66c676b2",
|
|
27
|
+
"package_path": "packages/portal-mojo/src/admin/identity/users/PersonalApiKeysPage.tsx",
|
|
28
|
+
"package_sha256": "f377d8a97b5c0ccbce745c333fdd7e4a5fd5f5a89b868b058677b31e3efe64ff",
|
|
29
|
+
"adaptation": "Expanded row facts and actions are presented in a native DetailView modal that observes the current API key record."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"source_path": "apps/portal/src/pages/GroupDetail.tsx",
|
|
33
|
+
"source_sha256": "01a8a6032a59a337636ec28e5e2aef90717e5bc00656807135e02820a92a394b",
|
|
34
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/GroupDetail.tsx",
|
|
35
|
+
"package_sha256": "e26943218e3e8f5ac79ddc7f1b0bd40736bcab0bd6e9c694e01830086349a4e2",
|
|
36
|
+
"adaptation": "Raw metadata is visible in the explicit Metadata section without a collapsible control."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"source_path": "apps/portal/src/pages/GroupsPage.tsx",
|
|
40
|
+
"source_sha256": "82e4aa5e7ebf67832bbd3bcf7e15dcfdeea64383039cc426591fa15262e4d4c8",
|
|
41
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/GroupsPage.tsx",
|
|
42
|
+
"package_sha256": "e16f3d0630fdb1937cf667cd2abe8cd1aabc9bb2b731113112930e66bde2f1df",
|
|
43
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"source_path": "apps/portal/src/pages/group-sections/AuditSection.tsx",
|
|
47
|
+
"source_sha256": "49848f57e5050c0008aa83f7092af9abb8f55d262791a2591e525eaa6d741df4",
|
|
48
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/AuditSection.tsx",
|
|
49
|
+
"package_sha256": "07a40636549a0a1d8636a115ee330313b84c0c82500890c6464301fd39de1e06",
|
|
50
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"source_path": "apps/portal/src/pages/group-sections/AuthConfigDialog.tsx",
|
|
54
|
+
"source_sha256": "f42e18a802f206a2b3f30248c30ad12887598b170deb73b532232a6bf89699dd",
|
|
55
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx",
|
|
56
|
+
"package_sha256": "e2280185bde7eab3ca7108f7d6da9e0e66c4f4d8323ec97f3fd7d49376932b65",
|
|
57
|
+
"adaptation": "Raw auth overrides remain visible in the explicit configuration dialog without collapsible content."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"source_path": "apps/portal/src/pages/group-sections/EventsSection.tsx",
|
|
61
|
+
"source_sha256": "b8cb12ae99d7c8d5931a090b7be8ea4e0373ff4657e786094e9ac862167d43ec",
|
|
62
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/EventsSection.tsx",
|
|
63
|
+
"package_sha256": "f0004605fbf7ceb2704adb42c0a3dd14dc5a468c445f0f94f2548fbc5f1bfeb4",
|
|
64
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"source_path": "apps/portal/src/pages/group-sections/GeofenceSection.tsx",
|
|
68
|
+
"source_sha256": "d0ce22f5ef922371d7a78c353e1a29e5d9ca4932e45814499e9c3cdeda7dc137",
|
|
69
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/GeofenceSection.tsx",
|
|
70
|
+
"package_sha256": "227850a8ef844867ea55d599997144b462ee058967224c361dd821713d1b677a",
|
|
71
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"source_path": "apps/portal/src/pages/group-sections/IdentitySection.tsx",
|
|
75
|
+
"source_sha256": "cda776a26f1e21b73820ed88ab7dcbafdaf2b146c4c3ee4bf6b24dcbaeecc9f4",
|
|
76
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/IdentitySection.tsx",
|
|
77
|
+
"package_sha256": "888ff5f7a70c80d8a868f13675b39af9a2a7076d6ea5fbc323493c0265ce9880",
|
|
78
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"source_path": "apps/portal/src/pages/group-sections/MembersSection.tsx",
|
|
82
|
+
"source_sha256": "dc2e99924b8ba856ce2ed48dffae082e98cac9ef78df3ddb1f05fe8a71f9725d",
|
|
83
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/MembersSection.tsx",
|
|
84
|
+
"package_sha256": "abccb58bcdb62d0d1de2611cad080ca7237e0dfa781b10c5fc1d7e251398f628",
|
|
85
|
+
"adaptation": "Passes the group navigation callback into UserDetail to keep cross-record navigation available."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"source_path": "apps/portal/src/pages/group-sections/OverviewSection.tsx",
|
|
89
|
+
"source_sha256": "fb395569018b89e6efd45cea241ba665b73efd15549802a6f0d98d90116ce894",
|
|
90
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/OverviewSection.tsx",
|
|
91
|
+
"package_sha256": "08b460272af71ba6209fe388b71f37f79d94a9fecc21c0c2ee3abaf11f01372f",
|
|
92
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"source_path": "apps/portal/src/pages/group-sections/SubGroupsSection.tsx",
|
|
96
|
+
"source_sha256": "81074a2d74d80fda93e40379b87620807c31db7979736f90559e359bdafc0672",
|
|
97
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/SubGroupsSection.tsx",
|
|
98
|
+
"package_sha256": "61e412e709afb4384fdcbaa1d8fbb6df6ba414634ecc6aaa8201c66cf722af93",
|
|
99
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"source_path": "apps/portal/src/pages/group-sections/auth-config.ts",
|
|
103
|
+
"source_sha256": "37d75dff84bca2577bcd1804c77aa0ef1519e4d913dcfdf7914266045fdbabe6",
|
|
104
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/auth-config.ts",
|
|
105
|
+
"package_sha256": "37d75dff84bca2577bcd1804c77aa0ef1519e4d913dcfdf7914266045fdbabe6",
|
|
106
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"source_path": "apps/portal/src/pages/group-sections/geofence-data.ts",
|
|
110
|
+
"source_sha256": "169d5feb72fbe16a0463d591328ce49963e95f1bc0f5f7e5fa9ed271a5ace8e6",
|
|
111
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/geofence-data.ts",
|
|
112
|
+
"package_sha256": "169d5feb72fbe16a0463d591328ce49963e95f1bc0f5f7e5fa9ed271a5ace8e6",
|
|
113
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"source_path": "apps/portal/src/pages/group-sections/models.ts",
|
|
117
|
+
"source_sha256": "ec6a2fd700a0a91e8f1d9e49c364ffe5466db340b435004971e719da08b49371",
|
|
118
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/models.ts",
|
|
119
|
+
"package_sha256": "ec6a2fd700a0a91e8f1d9e49c364ffe5466db340b435004971e719da08b49371",
|
|
120
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"source_path": "apps/portal/src/pages/group-sections/shared.ts",
|
|
124
|
+
"source_sha256": "68aa819b14edcbbd8ae399d5c5a74360cc50ffd281b6d0d3be1c4f8ebc4e4f6c",
|
|
125
|
+
"package_path": "packages/portal-mojo/src/admin/identity/groups/group-sections/shared.ts",
|
|
126
|
+
"package_sha256": "68aa819b14edcbbd8ae399d5c5a74360cc50ffd281b6d0d3be1c4f8ebc4e4f6c",
|
|
127
|
+
"adaptation": "Moved into the package identity domain and updated package-relative imports."
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Admin Groups and Personal API Keys
|
|
2
|
+
|
|
3
|
+
The global Admin registry contributes Groups and Personal API Keys to the
|
|
4
|
+
Identity & Access navigation group. `adminSectionRoutes()` and
|
|
5
|
+
`adminSectionsMenu()` therefore mount the same pages in the standalone
|
|
6
|
+
reference portal and in an embedded System Admin boundary:
|
|
7
|
+
|
|
8
|
+
```tsx
|
|
9
|
+
import {
|
|
10
|
+
GROUPS_ADMIN_SECTION,
|
|
11
|
+
GROUP_VIEW_PERMS,
|
|
12
|
+
GroupDetail,
|
|
13
|
+
GroupModel,
|
|
14
|
+
GroupsPage,
|
|
15
|
+
PERSONAL_API_KEYS_ADMIN_SECTION,
|
|
16
|
+
PersonalApiKeysPage,
|
|
17
|
+
} from 'portal-mojo/admin/identity';
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The registered routes are `/groups` and `/apikeys`; an embedded mount prefixes
|
|
21
|
+
both routes. Group directory and detail access use the system-pinned
|
|
22
|
+
`sys.groups | sys.view_groups` clause. Group mutations use
|
|
23
|
+
`sys.groups | sys.manage_groups`. Active-group membership cannot satisfy
|
|
24
|
+
these gates. The personal key section uses the system identity-view clause,
|
|
25
|
+
while its API requests remain scoped to the signed-in user's own keys.
|
|
26
|
+
|
|
27
|
+
## Groups
|
|
28
|
+
|
|
29
|
+
`GroupsPage` retains the native server-owned `ModelTable` search, filters,
|
|
30
|
+
presets, paging, sorting, column chooser, persistence and exports against
|
|
31
|
+
`GET /api/group`. Opening a row presents `GroupDetail` in `modal.detail`.
|
|
32
|
+
`GroupDetail` preserves the Overview, Identity, Members, Sub-Groups, API Keys,
|
|
33
|
+
Webhooks, Geofencing, Events, Audit and Metadata surfaces, with each section's
|
|
34
|
+
existing system permission gate and conditional queries. Parent, child, member
|
|
35
|
+
and user navigation opens native detail modals while keeping the directory
|
|
36
|
+
underneath.
|
|
37
|
+
|
|
38
|
+
The group model and section code are adapted from the reference portal at
|
|
39
|
+
`v0.2.5` commit `93c7fdfeb3096d33010e72bd4483cf9ca6e3d6f5`. The source hashes
|
|
40
|
+
and corresponding package hashes are recorded in
|
|
41
|
+
[`admin-identity-directories-sources.json`](admin-identity-directories-sources.json).
|
|
42
|
+
The moved implementation retains the package Apache-2.0 license. Two raw JSON
|
|
43
|
+
surfaces render their values directly in their explicit Metadata/Auth details;
|
|
44
|
+
they do not use collapsible content.
|
|
45
|
+
|
|
46
|
+
## Personal API Keys
|
|
47
|
+
|
|
48
|
+
`PersonalApiKeysPage` uses the existing `ApiKeyModel` at
|
|
49
|
+
`/api/account/api_keys` and the caller-only generation mutation at
|
|
50
|
+
`/api/auth/generate_api_key`. The generated token is shown once and scrubbed
|
|
51
|
+
before it can reach the query or mutation cache. Key facts and actions open in
|
|
52
|
+
a native detail modal. Disable/enable remains reversible with an undo toast;
|
|
53
|
+
revoke remains armed because it rotates the secret and cannot be undone. There
|
|
54
|
+
is no delete action. This page does not use the separate group credential
|
|
55
|
+
endpoint.
|
|
56
|
+
|
|
57
|
+
The Personal API Keys route was added to the package identity section registry
|
|
58
|
+
alongside Users, Members, Groups, Credentials and Sign-in. Compatibility page
|
|
59
|
+
paths in the reference app re-export the package-owned pages, and its menu and
|
|
60
|
+
route composition do not inject duplicate directory entries.
|
|
@@ -25,7 +25,12 @@ 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.
|
|
28
|
+
Audit, Notifications, and Metadata. Permission sections are labeled **System
|
|
29
|
+
permissions** and **Product permissions** in the UI. Consuming apps install
|
|
30
|
+
their product tabs with `registerFormTabs(USER_APP_PERMS_TABSET, tabs)`; the
|
|
31
|
+
section stays absent when none are registered. User grants can cover multiple
|
|
32
|
+
groups; use a Member product permission for a single group.
|
|
33
|
+
Subsidiary count/list queries are enabled
|
|
29
34
|
only when their exact system permission is present. Logs use
|
|
30
35
|
`sys.view_logs | sys.manage_logs | sys.security`; incident events use
|
|
31
36
|
`sys.view_security | sys.security`. Permission revocation therefore removes
|
package/docs/admin-members.md
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
MEMBERS_ADMIN_SECTION,
|
|
13
13
|
openMemberAdmissionDialog,
|
|
14
14
|
registerMemberPermissions,
|
|
15
|
+
MEMBER_APP_PERMS_TABSET,
|
|
15
16
|
} from 'portal-mojo/admin';
|
|
16
17
|
```
|
|
17
18
|
|
|
@@ -103,6 +104,16 @@ and read-only rather than disappearing. Permission switches autosave through
|
|
|
103
104
|
`FormView`; a deployment's `MEMBER_PERMS_PROTECTION` remains authoritative,
|
|
104
105
|
and rejected changes revert with the server error shown.
|
|
105
106
|
|
|
107
|
+
For a larger product catalog, also register native `FormView` tabs under
|
|
108
|
+
`MEMBER_APP_PERMS_TABSET` with `registerFormTabs` from `portal-mojo/ui`.
|
|
109
|
+
Use dotted `permissions.<grant>` fields and retain the corresponding
|
|
110
|
+
`registerMemberPermissions` definitions. A separate **Product permissions**
|
|
111
|
+
detail section appears when this tabset is populated. Fields covered by those
|
|
112
|
+
tabs leave the ordinary **Permissions** section, so each grant has one editor.
|
|
113
|
+
Both forms save the same membership row with the same gates, partial-dict
|
|
114
|
+
merge, escalation confirmation and rejection handling. The tabset creates no
|
|
115
|
+
new grant semantics: the backend still decides what each key authorizes.
|
|
116
|
+
|
|
106
117
|
## Composition
|
|
107
118
|
|
|
108
119
|
`MEMBERS_ADMIN_SECTION` is root-relative and dual-mounts as `/members` in the
|
|
@@ -110,3 +121,8 @@ standalone Admin and `/system/members` when embedded. It never consumes active
|
|
|
110
121
|
group context. `GroupMembersPanel` is the reusable Group-detail composition;
|
|
111
122
|
pass optional `onNavigateUser` and `onNavigateGroup` callbacks to connect
|
|
112
123
|
sibling identity details without coupling the package to an app.
|
|
124
|
+
|
|
125
|
+
Member product tabs only render `permissions.<grant>` fields whose grant is
|
|
126
|
+
registered through `registerMemberPermissions()` and editable. System/derived
|
|
127
|
+
grants, unregistered names and unrelated fields (such as `group` or `user`)
|
|
128
|
+
are ignored; tabs with no valid fields do not appear.
|
|
@@ -163,7 +163,7 @@ flag is detected, `true` REQUIRES the flag (rare), absent/`null` means don't
|
|
|
163
163
|
care.
|
|
164
164
|
|
|
165
165
|
`geofence-data.ts` is the ONE definition of the lossy projection between that
|
|
166
|
-
DSL and the friendly form. `
|
|
166
|
+
DSL and the friendly form. `packages/portal-mojo/src/admin/identity/groups/group-sections/geofence-data.ts`
|
|
167
167
|
is a re-export shim over it, so the platform page and the group panel cannot
|
|
168
168
|
drift. `GeofenceRuleEditor` is the ONE editor both render.
|
|
169
169
|
|
package/docs/menus-and-access.md
CHANGED
|
@@ -26,8 +26,10 @@ setDefaultMenu('admin');
|
|
|
26
26
|
Resolution: route containment in registration order (group menus eligible
|
|
27
27
|
only under a kind-matched active group) → defaultMenu → first non-group
|
|
28
28
|
visible. Items support `permissions` (any-of) ∧ `requiresGroupKind`;
|
|
29
|
-
dividers with no visible children drop
|
|
30
|
-
|
|
29
|
+
dividers with no visible children drop. Only the destination row is selected;
|
|
30
|
+
an accordion parent is a neutral disclosure control and opens for its active
|
|
31
|
+
child. `exact: true` end-matches both the visual selection and `aria-current`.
|
|
32
|
+
`scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
|
|
31
33
|
explicit; legacy `groupKind` menus still infer group scope. Static rendering
|
|
32
34
|
remains the default for compatibility. `presentation: 'accordion'` enables
|
|
33
35
|
searchable, single-open-section navigation. Give accordion parents stable
|
|
@@ -57,6 +59,8 @@ Admin section bundles contribute through `adminSectionsMenu`. Use
|
|
|
57
59
|
`{grouped: true, presentation: 'accordion'}` for the domain-grouped Admin
|
|
58
60
|
experience. Routes remain mount-relative; multiple embedded root sections
|
|
59
61
|
share one `/system` landing instead of emitting duplicate index routes.
|
|
62
|
+
Generated Admin destinations are exact matched, so an embedded mount’s root
|
|
63
|
+
Dashboard cannot remain selected on every nested page.
|
|
60
64
|
|
|
61
65
|
Import the route/menu machinery from `portal-mojo/admin/core`, then import only
|
|
62
66
|
the registry domains the shell installs. The stable domains are `identity`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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",
|
package/src/admin/core/index.ts
CHANGED
|
@@ -207,8 +207,8 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
|
|
|
207
207
|
const labeled = section.routes.filter((route) => route.label);
|
|
208
208
|
const destinations: MenuItem[] = labeled.length > 0 ? labeled.map((route) => ({
|
|
209
209
|
id: `admin:${section.id}:${route.path || 'index'}`, label: route.label!, keywords: [section.title],
|
|
210
|
-
route: absolutePath(base, route.path), permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
|
|
211
|
-
})) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), permissionClauses: [section.permissions] }];
|
|
210
|
+
route: absolutePath(base, route.path), exact: true, permissionClauses: [section.permissions, ...(route.permissions ? [route.permissions] : [])],
|
|
211
|
+
})) : [{ id: `admin:${section.id}`, label: section.title, keywords: [section.title], route: absolutePath(base), exact: true, permissionClauses: [section.permissions] }];
|
|
212
212
|
if (section.navigationGroup === null) {
|
|
213
213
|
items.push(...destinations.map((destination) => ({ ...destination, icon: section.icon })));
|
|
214
214
|
continue;
|
|
@@ -227,8 +227,8 @@ export function adminSectionsMenu(sections: readonly AdminSection[], opts: {
|
|
|
227
227
|
const base = relativePath(mount, section.basePath ?? section.id);
|
|
228
228
|
items.push({
|
|
229
229
|
label: section.title, icon: section.icon, permissions: section.permissions,
|
|
230
|
-
...(base ? { route: absolutePath(base) } : {}),
|
|
231
|
-
children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), permissions: route.permissions })),
|
|
230
|
+
...(base ? { route: absolutePath(base), exact: true } : {}),
|
|
231
|
+
children: labeled.map((route) => ({ label: route.label!, route: absolutePath(base, route.path), exact: true, permissions: route.permissions })),
|
|
232
232
|
});
|
|
233
233
|
}
|
|
234
234
|
return { name: opts.name ?? 'admin', scope: 'admin', presentation: opts.presentation ?? 'static', items };
|
|
@@ -3,6 +3,7 @@ import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
|
|
|
3
3
|
import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
|
|
4
4
|
import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
|
|
5
5
|
import { SIGNIN_ADMIN_PERMISSIONS } from '../signin/api';
|
|
6
|
+
import { GROUP_VIEW_PERMS } from '../identity/groups/group-permissions';
|
|
6
7
|
|
|
7
8
|
export const USERS_ADMIN_SECTION: AdminSection = {
|
|
8
9
|
id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
|
|
@@ -27,4 +28,21 @@ export const SIGNIN_ADMIN_SECTION: AdminSection = {
|
|
|
27
28
|
routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
export const
|
|
31
|
+
export const GROUPS_ADMIN_SECTION: AdminSection = {
|
|
32
|
+
id: 'groups', basePath: '', title: 'Groups', icon: 'bi-diagram-3', navigationGroup: 'identity-access', permissions: GROUP_VIEW_PERMS,
|
|
33
|
+
routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(({ GroupsPage }) => ({ default: GroupsPage })), permissions: GROUP_VIEW_PERMS }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const PERSONAL_API_KEYS_ADMIN_SECTION: AdminSection = {
|
|
37
|
+
id: 'personal-api-keys', basePath: '', title: 'Personal API Keys', icon: 'bi-key', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
|
|
38
|
+
routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage })) }],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const IDENTITY_ADMIN_SECTIONS = [
|
|
42
|
+
GROUPS_ADMIN_SECTION,
|
|
43
|
+
PERSONAL_API_KEYS_ADMIN_SECTION,
|
|
44
|
+
USERS_ADMIN_SECTION,
|
|
45
|
+
MEMBERS_ADMIN_SECTION,
|
|
46
|
+
CREDENTIALS_ADMIN_SECTION,
|
|
47
|
+
SIGNIN_ADMIN_SECTION,
|
|
48
|
+
] as const;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// GroupDetail — the FULL GroupView.js port (web-mojo admin/account/groups/
|
|
2
|
+
// GroupView.js, all 2243 lines read 2026-08-05; parity pass over the earlier
|
|
3
|
+
// subset build): DetailView modal with the complete section map —
|
|
4
|
+
// Overview · Identity · ─Membership─ Members · Sub-Groups · ─Access─
|
|
5
|
+
// API Keys · Webhooks(manage_group) · Geofencing(geofence perms) ·
|
|
6
|
+
// ─Activity─ Events · Audit(view_logs…) · ─Detail─ Metadata
|
|
7
|
+
// — kind-aware header (icon/noun/chips), rail count badges, active toggle
|
|
8
|
+
// gated to manage_groups (the source hid it below that tier), kebab context
|
|
9
|
+
// menu (Invite Member · Add Sub-{Noun} · Configure Auth · View Parent ·
|
|
10
|
+
// Deactivate/Activate), and cross-record navigation (parent/sub-group links
|
|
11
|
+
// open a STACKED GroupDetail — native <dialog> stacking).
|
|
12
|
+
//
|
|
13
|
+
// Deviations from source, all deliberate and documented in-section:
|
|
14
|
+
// · "Edit {Noun}" (the GroupForms.detailed modal) is not carried — the
|
|
15
|
+
// Identity section IS that form, as an autosave surface covering every
|
|
16
|
+
// field it had except avatar (no upload pipeline in portal-mojo yet).
|
|
17
|
+
// · The header aux meta line ("Last activity 50m ago" under the toggle)
|
|
18
|
+
// folds into the chips row — DetailView has no right-gutter meta slot;
|
|
19
|
+
// Overview's KPI carries the same fact.
|
|
20
|
+
// · Member rows use the packaged MemberDetail; optional callbacks keep the
|
|
21
|
+
// package decoupled while this app still opens UserDetail/GroupDetail.
|
|
22
|
+
// · Deactivate collects the REQUIRED reason (admin | abuse | archived —
|
|
23
|
+
// django-mojo services/disable.py GROUP_REST_REASONS); web-mojo's bare
|
|
24
|
+
// confirm predates that backend rule.
|
|
25
|
+
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
|
26
|
+
import {
|
|
27
|
+
DetailView, Eyebrow, MetadataSection, formModal, modal, toast,
|
|
28
|
+
type DetailMenuEntry,
|
|
29
|
+
} from 'portal-mojo/ui';
|
|
30
|
+
import { mojoList, useCan, type Params } from 'portal-mojo/client/runtime';
|
|
31
|
+
import {
|
|
32
|
+
GLOBAL_CREDENTIAL_PERMS, GroupApiKeyModel, GroupApiKeysSection,
|
|
33
|
+
WebhookSubscriptionModel, WebhookSubscriptionsSection,
|
|
34
|
+
} from '../../credentials';
|
|
35
|
+
import {
|
|
36
|
+
MEMBER_INVITE_PERMISSIONS, MEMBER_SAVE_PERMISSIONS, MEMBER_USER_DIRECTORY_PERMISSIONS,
|
|
37
|
+
MemberModel, openMemberAdmissionDialog,
|
|
38
|
+
} from '../members';
|
|
39
|
+
import { LogModel } from '../../monitoring';
|
|
40
|
+
import {
|
|
41
|
+
GROUP_DESTRUCTIVE_PERMS, GROUP_MANAGE_PERMS,
|
|
42
|
+
GroupModel, type GroupRow,
|
|
43
|
+
} from './models';
|
|
44
|
+
import {
|
|
45
|
+
iconForKind, kindLabel,
|
|
46
|
+
} from './group-sections/models';
|
|
47
|
+
import { GEOFENCE_VIEW_PERMS } from './group-sections/geofence-data';
|
|
48
|
+
import { groupAuditParams } from './group-sections/shared';
|
|
49
|
+
import { openAuthConfigDialog } from './group-sections/AuthConfigDialog';
|
|
50
|
+
import { OverviewSection } from './group-sections/OverviewSection';
|
|
51
|
+
import { IdentitySection } from './group-sections/IdentitySection';
|
|
52
|
+
import { MembersSection } from './group-sections/MembersSection';
|
|
53
|
+
import { SubGroupsSection, runAddSubGroupFlow } from './group-sections/SubGroupsSection';
|
|
54
|
+
import { GeofenceSection } from './group-sections/GeofenceSection';
|
|
55
|
+
import { EventsSection } from './group-sections/EventsSection';
|
|
56
|
+
import { AuditSection } from './group-sections/AuditSection';
|
|
57
|
+
|
|
58
|
+
export { iconForKind } from './group-sections/models';
|
|
59
|
+
|
|
60
|
+
const GLOBAL_SECURITY_PERMS = ['sys.view_security', 'sys.security'];
|
|
61
|
+
const GLOBAL_LOG_PERMS = ['sys.view_logs', 'sys.manage_logs', 'sys.security'];
|
|
62
|
+
|
|
63
|
+
/** Open another group's detail as a stacked modal (source _openGroupById). */
|
|
64
|
+
function openGroupById(id: number) {
|
|
65
|
+
void modal.detail((close) => <GroupDetail id={id} onClose={() => close(null)} />);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* count-only peek sharing useModelList's exact cache key ([endpoint,
|
|
70
|
+
* params]), so a section mounting the same list dedupes with the badge.
|
|
71
|
+
* `enabled:false` for gated surfaces (a non-manager's webhook badge must
|
|
72
|
+
* not fire a guaranteed-403 request per open).
|
|
73
|
+
*/
|
|
74
|
+
function useCountPeek(endpoint: string, params: Params, enabled = true): number | null {
|
|
75
|
+
const q = useQuery({
|
|
76
|
+
queryKey: [endpoint, params],
|
|
77
|
+
queryFn: () => mojoList(endpoint, params),
|
|
78
|
+
enabled,
|
|
79
|
+
});
|
|
80
|
+
return q.data?.count ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function GroupDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
84
|
+
const qc = useQueryClient();
|
|
85
|
+
const { data: group, isPending } = GroupModel.useOne(id);
|
|
86
|
+
const groupSave = GroupModel.useSave();
|
|
87
|
+
const disable = GroupModel.useAction('disable');
|
|
88
|
+
const reactivate = GroupModel.useAction('reactivate');
|
|
89
|
+
const { can: canManage } = useCan(GROUP_MANAGE_PERMS);
|
|
90
|
+
const { can: canAccessCredentials } = useCan(GLOBAL_CREDENTIAL_PERMS);
|
|
91
|
+
const { can: canViewAudit } = useCan(GLOBAL_LOG_PERMS);
|
|
92
|
+
const { can: canInviteMember } = useCan(MEMBER_INVITE_PERMISSIONS);
|
|
93
|
+
const { can: canCreateMember } = useCan(MEMBER_SAVE_PERMISSIONS);
|
|
94
|
+
const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
|
|
95
|
+
|
|
96
|
+
// Rail count badges (source setBadge wiring, as controlled props). The
|
|
97
|
+
// member/apikey peeks share Overview's keys and dedupe; the gated ones
|
|
98
|
+
// hold off until the caller could actually open the section.
|
|
99
|
+
const memberCount = useCountPeek(MemberModel.endpoint, { group: id, size: 1, is_active: true });
|
|
100
|
+
const subCount = useCountPeek(GroupModel.endpoint, { parent: id, size: 1 });
|
|
101
|
+
const keyCount = useCountPeek(GroupApiKeyModel.endpoint, { group: id, size: 1 }, canAccessCredentials);
|
|
102
|
+
const hookCount = useCountPeek(WebhookSubscriptionModel.endpoint, { group: id, size: 1 }, canAccessCredentials);
|
|
103
|
+
const auditCount = useCountPeek(LogModel.endpoint, { ...groupAuditParams(id), size: 1 }, canViewAudit);
|
|
104
|
+
|
|
105
|
+
if (isPending || !group) {
|
|
106
|
+
return <div className="detail-loading"><span className="skel skel-block" /></div>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const noun = kindLabel(group.kind) || 'Group';
|
|
110
|
+
|
|
111
|
+
/** Deactivate collects the REQUIRED reason (admin | abuse | archived). */
|
|
112
|
+
const deactivate = async () => {
|
|
113
|
+
const data = await formModal({
|
|
114
|
+
...GroupModel.forms.disable!,
|
|
115
|
+
title: `Deactivate ${noun.toLowerCase()}`,
|
|
116
|
+
intro: <>Deactivate <b>{group.name}</b>? Members keep their accounts; the group stops resolving.</>,
|
|
117
|
+
});
|
|
118
|
+
if (!data) return;
|
|
119
|
+
const payload: Record<string, unknown> = { reason: data.reason };
|
|
120
|
+
if (data.note) payload.note = data.note;
|
|
121
|
+
try {
|
|
122
|
+
await disable.mutateAsync({ id: group.id, payload });
|
|
123
|
+
toast.success(`${noun} deactivated`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
toast.error(err instanceof Error ? err.message : 'Deactivate failed');
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const activate = async () => {
|
|
130
|
+
try {
|
|
131
|
+
await reactivate.mutateAsync({ id: group.id });
|
|
132
|
+
toast.success(`${noun} reactivated`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
toast.error(err instanceof Error ? err.message : 'Reactivate failed');
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Kebab — the source contextItems (GroupView.js:1361-1373) minus "Edit
|
|
139
|
+
// {Noun}" (Identity autosave replaces it — see the header comment).
|
|
140
|
+
const MENU: DetailMenuEntry<GroupRow>[] = [
|
|
141
|
+
{
|
|
142
|
+
label: 'Add or Invite Member', icon: 'bi-person-plus',
|
|
143
|
+
permissions: MEMBER_INVITE_PERMISSIONS,
|
|
144
|
+
onSelect: () => {
|
|
145
|
+
void openMemberAdmissionDialog({
|
|
146
|
+
group,
|
|
147
|
+
canInvite: canInviteMember,
|
|
148
|
+
canCreate: canCreateMember,
|
|
149
|
+
canReadUsers,
|
|
150
|
+
queryClient: qc,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: `Add Sub-${noun}`, icon: 'bi-diagram-3',
|
|
156
|
+
permissions: GROUP_MANAGE_PERMS,
|
|
157
|
+
onSelect: () => { void runAddSubGroupFlow(group, groupSave.mutateAsync); },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
label: 'Configure Auth', icon: 'bi-box-arrow-in-right',
|
|
161
|
+
permissions: GROUP_MANAGE_PERMS,
|
|
162
|
+
onSelect: () => { openAuthConfigDialog(group); },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
label: 'View Parent', icon: 'bi-arrow-up-right-square',
|
|
166
|
+
when: (g) => Boolean(g?.parent?.id),
|
|
167
|
+
onSelect: () => { if (group.parent?.id) openGroupById(group.parent.id); },
|
|
168
|
+
},
|
|
169
|
+
{ divider: true },
|
|
170
|
+
{
|
|
171
|
+
label: group.is_active ? `Deactivate ${noun}` : `Activate ${noun}`,
|
|
172
|
+
icon: group.is_active ? 'bi-toggle-off' : 'bi-toggle-on',
|
|
173
|
+
danger: group.is_active,
|
|
174
|
+
permissions: GROUP_DESTRUCTIVE_PERMS,
|
|
175
|
+
onSelect: () => { void (group.is_active ? deactivate() : activate()); },
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const shownMembers = memberCount ?? group.member_count;
|
|
180
|
+
const timezone = typeof group.metadata?.timezone === 'string' ? group.metadata.timezone : null;
|
|
181
|
+
const eodRaw = group.metadata?.eod_hour;
|
|
182
|
+
const eodChip = eodRaw !== undefined && eodRaw !== null && eodRaw !== ''
|
|
183
|
+
? `EOD ${String(eodRaw).padStart(2, '0')}:00` : null;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<DetailView<GroupRow>
|
|
187
|
+
icon={iconForKind(group.kind)}
|
|
188
|
+
title={group.name}
|
|
189
|
+
subtitle={group.parent?.name ?? undefined}
|
|
190
|
+
chips={[
|
|
191
|
+
...(group.kind ? [{ text: kindLabel(group.kind), tone: 'primary' as const }] : []),
|
|
192
|
+
...(shownMembers > 0 ? [{ icon: 'bi-people', text: `${shownMembers} member${shownMembers === 1 ? '' : 's'}`, tone: 'muted' as const }] : []),
|
|
193
|
+
...(subCount != null && subCount > 0 ? [{ icon: 'bi-diagram-3', text: `${subCount} sub-group${subCount === 1 ? '' : 's'}`, tone: 'muted' as const }] : []),
|
|
194
|
+
...(timezone ? [{ text: timezone, tone: 'muted' as const }] : []),
|
|
195
|
+
...(eodChip ? [{ text: eodChip, tone: 'muted' as const }] : []),
|
|
196
|
+
...(group.metadata?.portal ? [{ icon: 'bi-globe', text: 'Has portal', tone: 'muted' as const }] : []),
|
|
197
|
+
]}
|
|
198
|
+
// Toggle hidden below manage_groups (source _buildHeaderAux gate).
|
|
199
|
+
// Off → deactivate flow (cancel leaves it on); on → reactivate.
|
|
200
|
+
active={canManage
|
|
201
|
+
? { value: group.is_active, onChange: (next) => { void (next ? activate() : deactivate()); } }
|
|
202
|
+
: undefined}
|
|
203
|
+
contextMenu={MENU}
|
|
204
|
+
menuContext={group}
|
|
205
|
+
badges={{
|
|
206
|
+
Members: shownMembers > 0 ? shownMembers : null,
|
|
207
|
+
SubGroups: subCount != null && subCount > 0 ? subCount : null,
|
|
208
|
+
ApiKeys: keyCount != null && keyCount > 0 ? keyCount : null,
|
|
209
|
+
Webhooks: hookCount != null && hookCount > 0 ? hookCount : null,
|
|
210
|
+
Audit: auditCount != null && auditCount > 0 ? auditCount : null,
|
|
211
|
+
}}
|
|
212
|
+
onClose={onClose}
|
|
213
|
+
sections={[
|
|
214
|
+
{
|
|
215
|
+
key: 'Overview', label: 'Overview', icon: 'bi-grid-1x2',
|
|
216
|
+
render: () => (
|
|
217
|
+
<OverviewSection
|
|
218
|
+
group={group}
|
|
219
|
+
openGroup={openGroupById}
|
|
220
|
+
canViewCredentials={canAccessCredentials}
|
|
221
|
+
canViewAudit={canViewAudit}
|
|
222
|
+
/>
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
{ key: 'Identity', label: 'Identity', icon: 'bi-card-text', render: () => <IdentitySection group={group} editable={canManage} /> },
|
|
226
|
+
{ divider: 'Membership' },
|
|
227
|
+
{ key: 'Members', label: 'Members', icon: 'bi-people', render: () => <MembersSection group={group} openGroup={openGroupById} /> },
|
|
228
|
+
{ key: 'SubGroups', label: 'Sub-Groups', icon: 'bi-diagram-3', render: () => <SubGroupsSection group={group} openGroup={openGroupById} /> },
|
|
229
|
+
{ divider: 'Access' },
|
|
230
|
+
{
|
|
231
|
+
key: 'ApiKeys', label: 'API Keys', icon: 'bi-key',
|
|
232
|
+
permissions: GLOBAL_CREDENTIAL_PERMS,
|
|
233
|
+
render: () => <GroupApiKeysSection group={group} permission={GLOBAL_CREDENTIAL_PERMS} />,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
key: 'Webhooks', label: 'Webhooks', icon: 'bi-broadcast',
|
|
237
|
+
permissions: GLOBAL_CREDENTIAL_PERMS,
|
|
238
|
+
render: () => <WebhookSubscriptionsSection group={group} permission={GLOBAL_CREDENTIAL_PERMS} />,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
key: 'Geofencing', label: 'Geofencing', icon: 'bi-globe-americas',
|
|
242
|
+
permissions: GEOFENCE_VIEW_PERMS,
|
|
243
|
+
render: () => <GeofenceSection group={group} />,
|
|
244
|
+
},
|
|
245
|
+
{ divider: 'Activity' },
|
|
246
|
+
{
|
|
247
|
+
key: 'Events', label: 'Events', icon: 'bi-calendar-event',
|
|
248
|
+
permissions: GLOBAL_SECURITY_PERMS,
|
|
249
|
+
render: () => <EventsSection group={group} />,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
key: 'Audit', label: 'Audit', icon: 'bi-clock-history',
|
|
253
|
+
// Wider than the source's bare 'view_logs': the fetch
|
|
254
|
+
// itself needs logit's VIEW_PERMS, so the gate matches
|
|
255
|
+
// what can actually load (fail-closed alignment).
|
|
256
|
+
permissions: GLOBAL_LOG_PERMS,
|
|
257
|
+
render: () => <AuditSection group={group} />,
|
|
258
|
+
},
|
|
259
|
+
{ divider: 'Detail' },
|
|
260
|
+
{
|
|
261
|
+
key: 'Metadata', label: 'Metadata', icon: 'bi-braces',
|
|
262
|
+
render: () => (
|
|
263
|
+
<>
|
|
264
|
+
{canManage && (
|
|
265
|
+
<MetadataSection
|
|
266
|
+
endpoint={GroupModel.endpoint}
|
|
267
|
+
id={group.id}
|
|
268
|
+
metadata={group.metadata ?? {}}
|
|
269
|
+
onSaved={(next) => {
|
|
270
|
+
// Owner write-through + refetch (the primitive is
|
|
271
|
+
// controlled — it never mirrors server state).
|
|
272
|
+
qc.setQueryData(GroupModel.keys.one(group.id), { ...group, metadata: next });
|
|
273
|
+
void GroupModel.invalidate(qc);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
<div style={{ marginTop: 16 }}>
|
|
278
|
+
<Eyebrow>Raw metadata (JSON)</Eyebrow>
|
|
279
|
+
<pre className="dim" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}><code>{JSON.stringify(group.metadata ?? {}, null, 2)}</code></pre>
|
|
280
|
+
</div>
|
|
281
|
+
</>
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
]}
|
|
285
|
+
/>
|
|
286
|
+
);
|
|
287
|
+
}
|