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.
Files changed (35) hide show
  1. package/docs/admin-identity-directories-sources.json +130 -0
  2. package/docs/admin-identity-directories.md +60 -0
  3. package/docs/admin-identity-users.md +6 -1
  4. package/docs/admin-members.md +16 -0
  5. package/docs/admin-network-security.md +1 -1
  6. package/docs/menus-and-access.md +6 -2
  7. package/package.json +1 -1
  8. package/src/admin/core/index.ts +4 -4
  9. package/src/admin/domains/identity.ts +19 -1
  10. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  11. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  12. package/src/admin/identity/groups/group-permissions.ts +5 -0
  13. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  14. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  15. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  16. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  17. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  18. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  19. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  20. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  21. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  22. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  23. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  24. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  25. package/src/admin/identity/groups/index.ts +6 -0
  26. package/src/admin/identity/groups/models.ts +70 -0
  27. package/src/admin/identity/index.ts +1 -0
  28. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  29. package/src/admin/identity/members/models.ts +3 -0
  30. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  31. package/src/admin/identity/users/UserDetail.tsx +2 -2
  32. package/src/admin/identity/users/index.ts +2 -0
  33. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  34. package/src/admin/registry.ts +3 -1
  35. 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. Subsidiary count/list queries are enabled
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
@@ -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. `apps/portal/src/pages/group-sections/geofence-data.ts`
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
 
@@ -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; a parent lights when a child route
30
- is active. `scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
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.5",
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",
@@ -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 IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION] as 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
+ }