@jini-ai/user-management 0.3.3 → 0.3.4

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 (68) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/admin/adapters/memory.d.ts +3 -5
  3. package/dist/admin/adapters/memory.js +27 -28
  4. package/dist/admin/conformance/index.js +1 -0
  5. package/dist/admin/controllers/auth.controller.js +8 -6
  6. package/dist/admin/controllers/members.controller.js +9 -6
  7. package/dist/admin/controllers/roles.controller.js +99 -105
  8. package/dist/admin/controllers/users.controller.js +56 -36
  9. package/dist/admin/error-copy.d.ts +11 -0
  10. package/dist/admin/error-copy.js +10 -0
  11. package/dist/admin/people.rules.js +37 -31
  12. package/dist/admin/react/components/MemberRow.d.ts +1 -1
  13. package/dist/admin/react/components/MemberRow.js +12 -2
  14. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
  15. package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
  16. package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
  17. package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
  18. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
  19. package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
  20. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
  21. package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
  22. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
  23. package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
  24. package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
  25. package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
  26. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
  27. package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
  28. package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
  29. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
  30. package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
  31. package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
  32. package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
  33. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
  34. package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
  35. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
  36. package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
  37. package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
  38. package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
  39. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
  40. package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
  41. package/dist/admin/react/index.d.ts +4 -4
  42. package/dist/admin/react/pages/LoginPage.d.ts +1 -1
  43. package/dist/admin/react/pages/MembersPage.d.ts +1 -1
  44. package/dist/admin/react/pages/RolesPage.d.ts +2 -2
  45. package/dist/admin/react/pages/RolesPage.js +6 -2
  46. package/dist/admin/react/pages/UsersPage.d.ts +2 -2
  47. package/dist/admin/react/pages/UsersPage.js +6 -2
  48. package/dist/admin/rules.js +14 -12
  49. package/dist/react/errors.js +16 -9
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/members/hooks/use-members.hooks.js +21 -12
  52. package/dist/react/testing.js +4 -6
  53. package/dist/react/users/UserRowParts.d.ts +53 -0
  54. package/dist/react/users/UserRowParts.js +39 -0
  55. package/dist/react/users/Users.d.ts +31 -2
  56. package/dist/react/users/Users.js +41 -36
  57. package/dist/react/users/hooks/use-users.hooks.js +57 -30
  58. package/dist/server/admin-crud-service.js +25 -13
  59. package/dist/server/agent-tool-input.js +62 -36
  60. package/dist/server/agent-tool-registrations.d.ts +46 -0
  61. package/dist/server/agent-tool-registrations.js +28 -20
  62. package/dist/server/auth-service.js +35 -20
  63. package/dist/server/authorize.js +7 -9
  64. package/dist/server/permission-migrations.js +35 -25
  65. package/dist/server/repo.memory-transactions.js +5 -6
  66. package/dist/server/repo.memory.js +35 -81
  67. package/dist/server/seed.js +100 -84
  68. package/package.json +8 -8
@@ -8,22 +8,36 @@ export function useRolesView(_required = {}, _optional = {}) {
8
8
  throw new Error('Roles page controller unavailable.');
9
9
  return view;
10
10
  }
11
+ /** A described tab id is namespaced (`roles.roles.policies`); its last segment keys `props.tabs`. */
12
+ function tabKey({ id }, _optional = {}) {
13
+ return id.slice(id.lastIndexOf('.') + 1);
14
+ }
15
+ /** Tabs to offer: none on a hidden page, else every visible described tab the host supplied. */
16
+ function visibleTabs(props, _optional = {}) {
17
+ if (!props.description.visible)
18
+ return [];
19
+ return props.description.tabs.filter(t => t.visible && props.tabs[tabKey(t)]).map(t => ({ ...t, key: tabKey(t) }));
20
+ }
21
+ /** The shell's destructive dialog; before the controller attaches it is closed and inert. */
22
+ function rolesConfirm({ controller, state }, _optional = {}) {
23
+ const pending = state?.pending ?? null;
24
+ const copy = destructiveCopy({ target: pending });
25
+ return { open: pending != null, title: copy.title, body: copy.body, confirmLabel: copy.label,
26
+ tone: copy.tone, consequence: copy.consequence, pending: state?.confirming ?? false, agentMayConfirm: false,
27
+ onConfirm: async () => { await controller?.confirmDestructive({}); }, onCancel: () => controller?.cancelDestructive({}) };
28
+ }
11
29
  export function useRolesPage(props, _optional = {}) {
12
30
  const { controller, state, permissions } = useRolesController({});
13
31
  const [selected, setSelected] = useState('roles');
14
32
  const canManage = canManageRoles({ permissions });
15
- const visible = props.description.visible ? props.description.tabs.filter(t => t.visible && props.tabs[t.id.slice(t.id.lastIndexOf('.') + 1)]) : [];
16
- const ids = visible.map(t => t.id.slice(t.id.lastIndexOf('.') + 1));
33
+ const visible = visibleTabs(props);
34
+ const ids = visible.map(t => t.key);
17
35
  // A stale bookmark opens a real visible tab; omitted/forbidden tabs cannot be rendered directly.
18
36
  const requested = props.requestedTab ?? selected;
19
37
  const tab = ids.includes(requested) ? requested : ids[0] ?? '';
20
- const items = visible.map(t => { const id = t.id.slice(t.id.lastIndexOf('.') + 1), Component = props.tabs[id]; return { id, label: t.label, content: id === tab ? createElement(Component, { params: t.params }) : null }; });
21
- const copy = destructiveCopy({ target: state?.pending ?? null });
22
- const confirm = { open: state?.pending != null, title: copy.title, body: copy.body, confirmLabel: copy.label,
23
- tone: copy.tone, consequence: copy.consequence, pending: state?.confirming ?? false, agentMayConfirm: false,
24
- onConfirm: async () => { await controller?.confirmDestructive({}); }, onCancel: () => controller?.cancelDestructive({}) };
38
+ const items = visible.map(t => ({ id: t.key, label: t.label, content: t.key === tab ? createElement(props.tabs[t.key], { params: t.params }) : null }));
25
39
  return {
26
- controller, state, canManage, items, tab, confirm,
40
+ controller, state, canManage, items, tab, confirm: rolesConfirm({ controller, state }),
27
41
  hidden: !props.description.visible, noTabs: visible.length === 0,
28
42
  view: controller && state ? { controller, state, canManage } : null,
29
43
  changeTab: ({ value }) => { setSelected(value); props.onTabChange?.({ tab: value }); },
@@ -0,0 +1,21 @@
1
+ import type { ComponentType, LazyExoticComponent } from 'react';
2
+ import type { AdminInstance } from '@jini-ai/admin/core/module';
3
+ import type { RolesApiPort } from '../../ports.js';
4
+ export interface RolesScope {
5
+ readonly api: RolesApiPort;
6
+ readonly permissions: readonly string[];
7
+ }
8
+ export declare const ScopeContext: import("react").Context<RolesScope | null>;
9
+ export interface RolesTabProps {
10
+ readonly params?: Readonly<Record<string, unknown>>;
11
+ }
12
+ export interface RolesPageProps {
13
+ readonly tabs: Readonly<Record<string, LazyExoticComponent<ComponentType<RolesTabProps>>>>;
14
+ readonly description: ReturnType<AdminInstance['describe']>['pages'][number];
15
+ readonly requestedTab?: string;
16
+ readonly onTabChange?: (required: {
17
+ tab: string;
18
+ }, optional?: Record<string, never>) => void;
19
+ }
20
+ export declare function useRolesScope(_required?: Record<string, never>, _optional?: Record<string, never>): RolesScope;
21
+ //# sourceMappingURL=RolesScope.hooks.d.ts.map
@@ -0,0 +1,10 @@
1
+ // Scope and page contracts must not import the lazy page/binding assembly.
2
+ import { createContext, useContext } from 'react';
3
+ export const ScopeContext = createContext(null);
4
+ export function useRolesScope(_required = {}, _optional = {}) {
5
+ const scope = useContext(ScopeContext);
6
+ if (!scope)
7
+ throw new Error('Roles admin scope is unavailable.');
8
+ return scope;
9
+ }
10
+ //# sourceMappingURL=RolesScope.hooks.js.map
@@ -1,30 +1,18 @@
1
- import type { ComponentType, LazyExoticComponent, ReactNode } from 'react';
1
+ import { type UsersScope } from './UsersScope.hooks.js';
2
+ export { useUsersScope } from './UsersScope.hooks.js';
3
+ export type { UsersScope, UsersPageProps } from './UsersScope.hooks.js';
4
+ import type { ReactNode } from 'react';
2
5
  import type { AdminInstance } from '@jini-ai/admin/core/module';
3
- import type { UsersApiPort, UsersSafetyPort } from '../../ports.js';
4
6
  import { usersModule } from '../../users.module.js';
5
- export interface UsersScope {
6
- readonly api: UsersApiPort;
7
- readonly permissions: readonly string[];
8
- readonly safety: UsersSafetyPort;
9
- }
10
- export interface UsersPageProps {
11
- readonly tabs: Readonly<Record<string, LazyExoticComponent<ComponentType<{
12
- readonly params?: Readonly<Record<string, unknown>>;
13
- }>>>>;
14
- readonly description: ReturnType<AdminInstance['describe']>['pages'][number];
15
- readonly openOwnPasswordReset?: boolean;
16
- readonly onOwnPasswordResetClosed?: (required: Record<string, never>, optional?: Record<string, never>) => void;
17
- }
18
- export declare function useUsersScope(_required?: Record<string, never>, _optional?: Record<string, never>): UsersScope;
19
7
  /** Descriptor identity must be the exact enriched object supplied to createAdmin. */
20
8
  export declare function createUsersBinding({ module }: {
21
9
  module: typeof usersModule;
22
10
  }, _optional?: Record<string, never>): {
23
11
  pages: {
24
12
  users: {
25
- Page: LazyExoticComponent<typeof import("../pages/UsersPage.js").default>;
13
+ Page: import("react").LazyExoticComponent<typeof import("../pages/UsersPage.js").default>;
26
14
  tabs: {
27
- users: LazyExoticComponent<typeof import("../tabs/UsersTab.js").default>;
15
+ users: import("react").LazyExoticComponent<typeof import("../tabs/UsersTab.js").default>;
28
16
  };
29
17
  };
30
18
  };
@@ -1,12 +1,7 @@
1
- import { createContext, createElement, lazy, useContext } from 'react';
1
+ import { ScopeContext } from './UsersScope.hooks.js';
2
+ export { useUsersScope } from './UsersScope.hooks.js';
3
+ import { createElement, lazy } from 'react';
2
4
  import { usersModule } from '../../users.module.js';
3
- const ScopeContext = createContext(null);
4
- export function useUsersScope(_required = {}, _optional = {}) {
5
- const scope = useContext(ScopeContext);
6
- if (!scope)
7
- throw new Error('Users admin scope is unavailable.');
8
- return scope;
9
- }
10
5
  /** Descriptor identity must be the exact enriched object supplied to createAdmin. */
11
6
  export function createUsersBinding({ module }, _optional = {}) {
12
7
  const pages = { users: { Page: lazy(() => import('../pages/UsersPage.js')), tabs: { users: lazy(() => import('../tabs/UsersTab.js')) } } };
@@ -1,6 +1,6 @@
1
1
  import type { UsersController } from '../../controllers/users.controller.js';
2
2
  import type { UsersState } from '../../models.js';
3
- import type { UsersPageProps } from './UsersBinding.hooks.js';
3
+ import type { UsersPageProps } from './UsersScope.hooks.js';
4
4
  export interface UsersView {
5
5
  controller: UsersController;
6
6
  state: UsersState;
@@ -1,7 +1,7 @@
1
1
  import { createElement, createContext, useContext, useMemo } from 'react';
2
2
  import { createUsersController } from '../../controllers/users.controller.js';
3
3
  import { hasAdminGrant } from '../../people.rules.js';
4
- import { useUsersScope } from './UsersBinding.hooks.js';
4
+ import { useUsersScope } from './UsersScope.hooks.js';
5
5
  import { usePeopleController } from './PeopleController.hooks.js';
6
6
  export const UsersViewContext = createContext(null);
7
7
  export function useUsersView(_required = {}, _optional = {}) { const v = useContext(UsersViewContext); if (!v)
@@ -0,0 +1,19 @@
1
+ import type { ComponentType, LazyExoticComponent } from 'react';
2
+ import type { AdminInstance } from '@jini-ai/admin/core/module';
3
+ import type { UsersApiPort, UsersSafetyPort } from '../../ports.js';
4
+ export interface UsersScope {
5
+ readonly api: UsersApiPort;
6
+ readonly permissions: readonly string[];
7
+ readonly safety: UsersSafetyPort;
8
+ }
9
+ export declare const ScopeContext: import("react").Context<UsersScope | null>;
10
+ export interface UsersPageProps {
11
+ readonly tabs: Readonly<Record<string, LazyExoticComponent<ComponentType<{
12
+ readonly params?: Readonly<Record<string, unknown>>;
13
+ }>>>>;
14
+ readonly description: ReturnType<AdminInstance['describe']>['pages'][number];
15
+ readonly openOwnPasswordReset?: boolean;
16
+ readonly onOwnPasswordResetClosed?: (required: Record<string, never>, optional?: Record<string, never>) => void;
17
+ }
18
+ export declare function useUsersScope(_required?: Record<string, never>, _optional?: Record<string, never>): UsersScope;
19
+ //# sourceMappingURL=UsersScope.hooks.d.ts.map
@@ -0,0 +1,10 @@
1
+ // Scope and page contracts must not import the lazy page/binding assembly.
2
+ import { createContext, useContext } from 'react';
3
+ export const ScopeContext = createContext(null);
4
+ export function useUsersScope(_required = {}, _optional = {}) {
5
+ const scope = useContext(ScopeContext);
6
+ if (!scope)
7
+ throw new Error('Users admin scope is unavailable.');
8
+ return scope;
9
+ }
10
+ //# sourceMappingURL=UsersScope.hooks.js.map
@@ -73,6 +73,12 @@ export declare function useUsersConfirmation(_required?: Record<string, never>,
73
73
  tone: "warning";
74
74
  body: string;
75
75
  consequence: string;
76
+ } | {
77
+ title: string;
78
+ label: string;
79
+ tone: "warning";
80
+ body: string;
81
+ consequence: string;
76
82
  };
77
83
  confirm: {
78
84
  open: boolean;
@@ -12,42 +12,54 @@ export function useUsersTab(_required = {}, _optional = {}) {
12
12
  submit: (e) => { e.preventDefault(); void c.createUser({}); },
13
13
  };
14
14
  }
15
+ /** Row actions in menu order; each is offered only when the controller allows it for this user. */
16
+ const rowActions = [
17
+ { action: 'disable', label: 'Disable', run: (c, principalId) => c.requestDestructive({ principalId, kind: 'disable' }) },
18
+ { action: 'enable', label: 'Enable', run: (c, principalId) => { void c.enableUser({ principalId }); } },
19
+ { action: 'reset', label: 'Reset password', run: (c, principalId) => c.requestDestructive({ principalId, kind: 'reset' }) },
20
+ { action: 'delete', label: 'Delete', run: (c, principalId) => c.requestDestructive({ principalId, kind: 'delete' }) },
21
+ ];
22
+ function grantOptions({ placeholder, records }, _optional = {}) {
23
+ return [{ value: '', label: placeholder }, ...records.map(r => ({ value: r.id, label: `${r.name}${r.isBuiltin ? ' (built-in)' : ''}` }))];
24
+ }
15
25
  export function useUserRow({ user }, _optional = {}) {
16
26
  const { controller: c, state: s } = useUsersView({});
17
27
  const id = user.principalId, canEmail = c.allowed({ principalId: id, action: 'email' }), canGrants = c.allowed({ principalId: id, action: 'grants' });
18
- const items = [];
19
- if (canEmail || canGrants)
20
- items.push({ id: 'manage', label: 'Manage', onPress: () => c.toggleExpanded({ principalId: id }) });
21
- if (c.allowed({ principalId: id, action: 'disable' }))
22
- items.push({ id: 'disable', label: 'Disable', onPress: () => c.requestDestructive({ principalId: id, kind: 'disable' }) });
23
- if (c.allowed({ principalId: id, action: 'enable' }))
24
- items.push({ id: 'enable', label: 'Enable', onPress: () => { void c.enableUser({ principalId: id }); } });
25
- if (c.allowed({ principalId: id, action: 'reset' }))
26
- items.push({ id: 'reset', label: 'Reset password', onPress: () => c.requestDestructive({ principalId: id, kind: 'reset' }) });
27
- if (c.allowed({ principalId: id, action: 'delete' }))
28
- items.push({ id: 'delete', label: 'Delete', onPress: () => c.requestDestructive({ principalId: id, kind: 'delete' }) });
29
- return { s, items, canEmail, canGrants, expanded: s.expandedId === id, canExpand: canEmail || canGrants,
30
- roles: grantLabel({ ids: user.roleIds, records: s.roles ?? [] }), policies: grantLabel({ ids: user.policyIds, records: s.policies ?? [] }),
28
+ const canExpand = canEmail || canGrants, roles = s.roles ?? [], policies = s.policies ?? [];
29
+ const items = canExpand ? [{ id: 'manage', label: 'Manage', onPress: () => c.toggleExpanded({ principalId: id }) }] : [];
30
+ for (const a of rowActions)
31
+ if (c.allowed({ principalId: id, action: a.action }))
32
+ items.push({ id: a.action, label: a.label, onPress: () => a.run(c, id) });
33
+ return { s, items, canEmail, canGrants, expanded: s.expandedId === id, canExpand,
34
+ roles: grantLabel({ ids: user.roleIds, records: roles }), policies: grantLabel({ ids: user.policyIds, records: policies }),
31
35
  toggle: () => c.toggleExpanded({ principalId: id }), menuLabel: `Actions for user "${user.username}"`,
32
36
  email: ({ value }) => c.setDraft({ patch: { editEmail: value } }), role: ({ value }) => c.setDraft({ patch: { pendingRoleId: value } }), policy: ({ value }) => c.setDraft({ patch: { pendingPolicyId: value } }),
33
37
  saveEmail: () => { void c.savePanel({ principalId: id, kind: 'email' }); }, assignRole: () => { void c.savePanel({ principalId: id, kind: 'role' }); }, attachPolicy: () => { void c.savePanel({ principalId: id, kind: 'policy' }); },
34
- roleOptions: [{ value: '', label: 'Select a role…' }, ...(s.roles ?? []).map(r => ({ value: r.id, label: `${r.name}${r.isBuiltin ? ' (built-in)' : ''}` }))],
35
- policyOptions: [{ value: '', label: 'Select a policy…' }, ...(s.policies ?? []).map(p => ({ value: p.id, label: `${p.name}${p.isBuiltin ? ' (built-in)' : ''}` }))], };
38
+ roleOptions: grantOptions({ placeholder: 'Select a role…', records: roles }),
39
+ policyOptions: grantOptions({ placeholder: 'Select a policy…', records: policies }), };
40
+ }
41
+ /** Dialog copy per pending kind; nothing pending (a closed dialog) reads as the disable copy. */
42
+ const confirmationCopy = {
43
+ delete: { title: 'Delete this user?', label: 'Move to trash', tone: 'danger',
44
+ body: 'They will be signed out and moved to the Trash. You can restore them there; they are deleted permanently after 60 days.', consequence: 'moves the user to the Trash' },
45
+ reset: { title: 'Reset password?', label: 'Reset password', tone: 'warning', body: 'Every active session for this user will be signed out.', consequence: 'revokes every active session' },
46
+ disable: { title: 'Disable this user?', label: 'Disable', tone: 'warning', body: 'They will not be able to sign in until re-enabled.', consequence: 'revokes sign-in access' },
47
+ };
48
+ /** A password field's reveal state: its input type and its toggle button's label. */
49
+ function revealState({ shown, name }, _optional = {}) {
50
+ return shown ? { type: 'text', label: `Hide ${name} password` } : { type: 'password', label: `Show ${name} password` };
36
51
  }
37
52
  export function useUsersConfirmation(_required = {}, _optional = {}) {
38
53
  const { state: s, controller: c } = useUsersView({});
39
- const kind = s.pending?.kind, reset = kind === 'reset', mismatch = s.newPassword !== s.confirmPassword;
40
- const copy = kind === 'delete' ? { title: 'Delete this user?', label: 'Move to trash', tone: 'danger',
41
- body: 'They will be signed out and moved to the Trash. You can restore them there; they are deleted permanently after 60 days.', consequence: 'moves the user to the Trash' }
42
- : reset ? { title: 'Reset password?', label: 'Reset password', tone: 'warning', body: 'Every active session for this user will be signed out.', consequence: 'revokes every active session' }
43
- : { title: 'Disable this user?', label: 'Disable', tone: 'warning', body: 'They will not be able to sign in until re-enabled.', consequence: 'revokes sign-in access' };
54
+ const kind = s.pending?.kind ?? 'disable', reset = kind === 'reset', mismatch = s.newPassword !== s.confirmPassword;
55
+ const copy = confirmationCopy[kind], shownNew = revealState({ shown: s.showNewPassword, name: 'new' }), shownConfirm = revealState({ shown: s.showConfirmPassword, name: 'confirm' });
44
56
  return { s, reset, mismatch, error: mismatch ? 'Passwords do not match.' : s.passwordError, copy,
45
57
  confirm: { open: s.pending !== null, title: copy.title, confirmLabel: copy.label, tone: copy.tone, consequence: copy.consequence, pending: s.confirming, agentMayConfirm: false,
46
58
  onConfirm: async () => { await c.confirmDestructive({}); }, onCancel: () => c.cancelDestructive({}) },
47
59
  newPassword: ({ value }) => c.setDraft({ patch: { newPassword: value } }),
48
60
  confirmPassword: ({ value }) => c.setDraft({ patch: { confirmPassword: value } }),
49
- newType: s.showNewPassword ? 'text' : 'password', confirmType: s.showConfirmPassword ? 'text' : 'password',
61
+ newType: shownNew.type, confirmType: shownConfirm.type,
50
62
  toggleNew: () => c.togglePasswordVisible({ field: 'new' }), toggleConfirm: () => c.togglePasswordVisible({ field: 'confirm' }),
51
- newLabel: s.showNewPassword ? 'Hide new password' : 'Show new password', confirmLabel: s.showConfirmPassword ? 'Hide confirm password' : 'Show confirm password', };
63
+ newLabel: shownNew.label, confirmLabel: shownConfirm.label, };
52
64
  }
53
65
  //# sourceMappingURL=UsersTab.hooks.js.map
@@ -54,7 +54,7 @@ export declare function roles(_required: Record<string, never>, _optional?: Reco
54
54
  Provider: ({ admin, children }: {
55
55
  admin: import("@jini-ai/admin/core/module").AdminInstance;
56
56
  children: import("react").ReactNode;
57
- }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/RolesBinding.hooks.js").RolesScope | null>>;
57
+ }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/RolesScope.hooks.js").RolesScope | null>>;
58
58
  };
59
59
  kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Tabs" | "Badge" | "Menu" | "Button" | "TextField" | "ConfirmDialog">;
60
60
  };
@@ -107,7 +107,7 @@ export declare function users(_required: Record<string, never>, _optional?: Reco
107
107
  Provider: ({ admin, children }: {
108
108
  admin: import("@jini-ai/admin/core/module").AdminInstance;
109
109
  children: import("react").ReactNode;
110
- }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/UsersBinding.hooks.js").UsersScope | null>>;
110
+ }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/UsersScope.hooks.js").UsersScope | null>>;
111
111
  };
112
112
  kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "TextField" | "Select" | "ConfirmDialog">;
113
113
  };
@@ -152,7 +152,7 @@ export declare function members(_required: Record<string, never>, _optional?: Re
152
152
  Provider: ({ admin, children }: {
153
153
  admin: import("@jini-ai/admin/core/module").AdminInstance;
154
154
  children: import("react").ReactNode;
155
- }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/MembersBinding.hooks.js").MembersScope | null>>;
155
+ }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/MembersScope.hooks.js").MembersScope | null>>;
156
156
  };
157
157
  kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Badge" | "Menu" | "Button" | "ConfirmDialog">;
158
158
  };
@@ -193,7 +193,7 @@ export declare function auth(_required: Record<string, never>, _optional?: Recor
193
193
  Provider: ({ admin, children }: {
194
194
  admin: import("@jini-ai/admin/core/module").AdminInstance;
195
195
  children: import("react").ReactNode;
196
- }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/AuthBinding.hooks.js").AuthScope | null>>;
196
+ }, _optional?: Record<string, never>) => import("react").FunctionComponentElement<import("react").ProviderProps<import("./hooks/AuthScope.hooks.js").AuthScope | null>>;
197
197
  };
198
198
  kitNeeds: import("@jini-ai/ui-kit").KitNeeds<"Notice" | "Spinner" | "Button" | "TextField">;
199
199
  };
@@ -1,3 +1,3 @@
1
- import type { AuthPageProps } from '../hooks/AuthBinding.hooks.js';
1
+ import type { AuthPageProps } from '../hooks/AuthScope.hooks.js';
2
2
  export default function LoginPage(props: AuthPageProps, _optional?: Record<string, never>): import("react").JSX.Element;
3
3
  //# sourceMappingURL=LoginPage.d.ts.map
@@ -1,3 +1,3 @@
1
- import type { MembersPageProps } from '../hooks/MembersBinding.hooks.js';
1
+ import type { MembersPageProps } from '../hooks/MembersScope.hooks.js';
2
2
  export default function MembersPage(props: MembersPageProps, _optional?: Record<string, never>): import("react").JSX.Element;
3
3
  //# sourceMappingURL=MembersPage.d.ts.map
@@ -1,3 +1,3 @@
1
- import type { RolesPageProps } from '../hooks/RolesBinding.hooks.js';
2
- export default function RolesPage(props: RolesPageProps, _optional?: Record<string, never>): import("react").JSX.Element;
1
+ import type { RolesPageProps } from '../hooks/RolesScope.hooks.js';
2
+ export default function RolesPage(props: RolesPageProps): import("react").JSX.Element;
3
3
  //# sourceMappingURL=RolesPage.d.ts.map
@@ -4,7 +4,11 @@ import { Button, Notice, Spinner, Tabs } from '@jini-ai/ui-kit/react';
4
4
  import { RolesViewContext, useRolesPage } from '../hooks/RolesPage.hooks.js';
5
5
  import { DestructiveDialog } from '../components/DestructiveDialog.js';
6
6
  import { rolesMessagesEn as m } from '../../messages.en.js';
7
- export default function RolesPage(props, _optional = {}) {
7
+ /** The loaded shell; RolesPage only renders it once the page view and both lists exist. */
8
+ function RolesShell({ v, view }) {
9
+ return _jsx(RolesViewContext.Provider, { value: view, children: _jsxs("div", { className: "page", children: [_jsxs("header", { children: [_jsx("p", { children: m.people }), _jsx("h1", { children: m.title }), _jsx("p", { children: m.description })] }), !v.canManage ? _jsx(Notice, { children: m.readOnly }) : null, view.state.rowError ? _jsx(Notice, { tone: "danger", children: view.state.rowError }) : null, v.noTabs ? _jsx(Notice, { children: "No roles tabs are enabled." }) : _jsx(Suspense, { fallback: _jsx(Spinner, { label: m.loading }), children: _jsx(Tabs, { label: m.title, value: v.tab, items: v.items, onValueChange: v.changeTab }) }), _jsx(DestructiveDialog, { ...v.confirm })] }) });
10
+ }
11
+ export default function RolesPage(props) {
8
12
  const v = useRolesPage(props);
9
13
  if (v.hidden)
10
14
  return _jsx(Notice, { children: "Roles administration is unavailable." });
@@ -14,6 +18,6 @@ export default function RolesPage(props, _optional = {}) {
14
18
  return _jsxs(Notice, { tone: "danger", children: [v.state.error, _jsx(Button, { onPress: v.retry, children: "Retry" })] });
15
19
  if (!v.view || !v.state.roles || !v.state.policies)
16
20
  return _jsx(Spinner, { label: m.loading });
17
- return _jsx(RolesViewContext.Provider, { value: v.view, children: _jsxs("div", { className: "page", children: [_jsxs("header", { children: [_jsx("p", { children: m.people }), _jsx("h1", { children: m.title }), _jsx("p", { children: m.description })] }), !v.canManage ? _jsx(Notice, { children: m.readOnly }) : null, v.state.rowError ? _jsx(Notice, { tone: "danger", children: v.state.rowError }) : null, v.noTabs ? _jsx(Notice, { children: "No roles tabs are enabled." }) : _jsx(Suspense, { fallback: _jsx(Spinner, { label: m.loading }), children: _jsx(Tabs, { label: m.title, value: v.tab, items: v.items, onValueChange: v.changeTab }) }), _jsx(DestructiveDialog, { ...v.confirm })] }) });
21
+ return _jsx(RolesShell, { v: v, view: v.view });
18
22
  }
19
23
  //# sourceMappingURL=RolesPage.js.map
@@ -1,3 +1,3 @@
1
- import type { UsersPageProps } from '../hooks/UsersBinding.hooks.js';
2
- export default function UsersPage(props: UsersPageProps, _optional?: Record<string, never>): import("react").JSX.Element;
1
+ import type { UsersPageProps } from '../hooks/UsersScope.hooks.js';
2
+ export default function UsersPage(props: UsersPageProps): import("react").JSX.Element;
3
3
  //# sourceMappingURL=UsersPage.d.ts.map
@@ -4,7 +4,11 @@ import { Button, Notice, Spinner } from '@jini-ai/ui-kit/react';
4
4
  import { useUsersPage, UsersViewContext } from '../hooks/UsersPage.hooks.js';
5
5
  import { usersMessagesEn as m } from '../../messages.en.js';
6
6
  import { UsersConfirmation } from '../components/UsersConfirmation.js';
7
- export default function UsersPage(props, _optional = {}) {
7
+ /** The loaded shell; UsersPage only renders it once the page view and all three lists exist. */
8
+ function UsersShell({ v, view }) {
9
+ return _jsx(UsersViewContext.Provider, { value: view, children: _jsxs("div", { className: "page", children: [_jsxs("header", { children: [_jsx("p", { children: "People" }), _jsx("h1", { children: m.title }), _jsx("p", { children: m.description }), _jsx("p", { children: m.passwordHelp })] }), v.readOnly ? _jsx(Notice, { children: m.readOnly }) : null, _jsx(Notice, { children: m.protected }), view.state.rowError ? _jsx(Notice, { tone: "danger", children: view.state.rowError }) : null, view.state.notice ? _jsx(Notice, { tone: "success", children: view.state.notice }) : null, _jsx(Suspense, { fallback: _jsx(Spinner, { label: m.loading }), children: v.content }), _jsx(UsersConfirmation, {})] }) });
10
+ }
11
+ export default function UsersPage(props) {
8
12
  const v = useUsersPage(props);
9
13
  if (!v.visible)
10
14
  return _jsx(Notice, { children: "Users administration is unavailable." });
@@ -14,6 +18,6 @@ export default function UsersPage(props, _optional = {}) {
14
18
  return _jsxs(Notice, { tone: "danger", children: [v.state.error, _jsx(Button, { onPress: v.retry, children: "Retry" })] });
15
19
  if (!v.view || !v.state.users || !v.state.roles || !v.state.policies)
16
20
  return _jsx(Spinner, { label: m.loading });
17
- return _jsx(UsersViewContext.Provider, { value: v.view, children: _jsxs("div", { className: "page", children: [_jsxs("header", { children: [_jsx("p", { children: "People" }), _jsx("h1", { children: m.title }), _jsx("p", { children: m.description }), _jsx("p", { children: m.passwordHelp })] }), v.readOnly ? _jsx(Notice, { children: m.readOnly }) : null, _jsx(Notice, { children: m.protected }), v.state.rowError ? _jsx(Notice, { tone: "danger", children: v.state.rowError }) : null, v.state.notice ? _jsx(Notice, { tone: "success", children: v.state.notice }) : null, _jsx(Suspense, { fallback: _jsx(Spinner, { label: m.loading }), children: v.content }), _jsx(UsersConfirmation, {})] }) });
21
+ return _jsx(UsersShell, { v: v, view: v.view });
18
22
  }
19
23
  //# sourceMappingURL=UsersPage.js.map
@@ -1,23 +1,25 @@
1
1
  import { rolesMessagesEn as m } from './messages.en.js';
2
+ import { hasAdminGrant } from './people.rules.js';
3
+ import { errorCode, errorMessageOr } from './error-copy.js';
2
4
  /** All writes share the actual server vocabulary. A missing grant is a denial. */
3
5
  export function canManageRoles({ permissions }, _optional = {}) {
4
- return permissions.includes('role.manage') || permissions.includes('*');
6
+ return hasAdminGrant({ permissions, permission: 'role.manage' });
5
7
  }
6
8
  export function mutableRole({ role }, _optional = {}) { return !role.isBuiltin; }
7
9
  export function mutablePolicy({ policy }, _optional = {}) { return !policy.isBuiltin && !policy.isFrozen; }
10
+ const ROLES_ERROR_COPY = Object.freeze({
11
+ FORBIDDEN: m.forbidden, RESOURCE_CONFLICT: m.conflict, PERMISSION_UNKNOWN: m.unknownPermission,
12
+ GRANT_EXCEEDS_ISSUER: m.exceedsIssuer,
13
+ });
8
14
  /** Conflict copy is feature-specific: references, rather than duplicate workspace slugs. */
9
15
  export function describeRolesError({ error, fallback }, _optional = {}) {
10
- const messages = {
11
- FORBIDDEN: m.forbidden, RESOURCE_CONFLICT: m.conflict, PERMISSION_UNKNOWN: m.unknownPermission,
12
- GRANT_EXCEEDS_ISSUER: m.exceedsIssuer,
13
- };
14
- if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
15
- if (error.code === 'VALIDATION_ERROR')
16
- return error instanceof Error && error.message ? error.message : m.validation;
17
- if (messages[error.code])
18
- return messages[error.code];
19
- }
20
- return error instanceof Error && error.message ? error.message : fallback;
16
+ const code = errorCode({ error });
17
+ if (code === 'VALIDATION_ERROR')
18
+ return errorMessageOr({ error, fallback: m.validation });
19
+ // Inherited keys resolve here (see the "reported, not fixed" pin in rules.pins.test.ts).
20
+ if (code !== undefined && ROLES_ERROR_COPY[code])
21
+ return ROLES_ERROR_COPY[code];
22
+ return errorMessageOr({ error, fallback });
21
23
  }
22
24
  export function destructiveCopy({ target }, _optional = {}) {
23
25
  if (target?.kind === 'permission')
@@ -1,13 +1,20 @@
1
+ /** A non-empty `Error.message`, or `undefined` for anything else — the "raw server text" both
2
+ * branches of {@link describeIdentityError} prefer over a translated default. */
3
+ function errorMessage(error) {
4
+ return error instanceof Error && error.message ? error.message : undefined;
5
+ }
6
+ /** The string `code` an Error or plain object carries, or `undefined` when there is none. */
7
+ function errorCode(error) {
8
+ return error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : undefined;
9
+ }
1
10
  /** Error codes are data, so any host transport can supply them without a shared error class. */
2
11
  export function describeIdentityError({ error, fallback, translate }, { messages = {} } = {}) {
3
- if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
4
- if (error.code === "VALIDATION_ERROR") {
5
- return error instanceof Error && error.message ? error.message : translate("Please correct the highlighted fields.");
6
- }
7
- const message = messages[error.code];
8
- if (message)
9
- return translate(message);
10
- }
11
- return error instanceof Error && error.message ? error.message : fallback;
12
+ const code = errorCode(error);
13
+ if (code === "VALIDATION_ERROR")
14
+ return errorMessage(error) ?? translate("Please correct the highlighted fields.");
15
+ const message = code === undefined ? undefined : messages[code];
16
+ if (message)
17
+ return translate(message);
18
+ return errorMessage(error) ?? fallback;
12
19
  }
13
20
  //# sourceMappingURL=errors.js.map
@@ -5,7 +5,7 @@ export type { LoginProps } from './auth/Login.js';
5
5
  export { useLogin } from './auth/hooks/use-login.hooks.js';
6
6
  export type { LoginController, LoginHookProps, LoginOptions } from './auth/hooks/use-login.hooks.js';
7
7
  export { Users, UserManagePanel } from './users/Users.js';
8
- export type { UsersProps, GrantOption, GrantSelectController, UserManageController, UserManagePanelProps, UserRowActionsController, UserRowProps, UsersTableProps } from './users/Users.js';
8
+ export type { UsersProps, UsersBaseProps, UsersHook, UsersHookDependencies, GrantOption, GrantSelectController, UserManageController, UserManagePanelProps, UserRowActionsController, UserRowProps, UsersTableProps } from './users/Users.js';
9
9
  export { useUsers } from './users/hooks/use-users.hooks.js';
10
10
  export type { UsersController, UsersDependencies, UsersOptions } from './users/hooks/use-users.hooks.js';
11
11
  export { useResetPasswordFields } from './users/hooks/use-reset-password-fields.hooks.js';
@@ -1,20 +1,31 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { describeApiError, emptyRowState } from "../rules.js";
3
3
  import { useCachedLoader, useFetchMutation, useFetchQuery, useInvalidate } from "@jini-ai/ui/fetch-query";
4
+ /**
5
+ * The spinner row for the detail panel: the expanded row while its keyed observer is still
6
+ * fetching with nothing cached, `null` otherwise. Pure so `useMembers` stays a flat wiring list.
7
+ */
8
+ function detailLoadingIdFor({ expandedId, detail }) {
9
+ return expandedId !== null && !detail.data && detail.isFetching ? expandedId : null;
10
+ }
4
11
  export function useMembers({ port, translate, queryScope = "members" }, { refresh } = {}) {
5
12
  const scopeRef = useRef(queryScope);
6
13
  scopeRef.current = queryScope;
7
- const boundT = translate;
8
14
  const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
15
+ /** `null` when there is nothing to describe, so each derived error string stays one line. */
16
+ const describeIfError = (error, fallback) => (error ? describeError(error, fallback) : null);
9
17
  const listKey = [queryScope, 'members', 'list'];
10
18
  const detailRoot = [queryScope, 'members', 'detail'];
11
19
  const invalidate = useInvalidate();
20
+ // One request description shared by the observer and the imperative loader, so both read the
21
+ // same key through the same fetch.
22
+ const listRequest = { key: listKey, fetch: () => port.listMembers({}) };
12
23
  // Mount reads and assistant refreshes can overlap; the shared cache owns list settlement
13
24
  // order so a slow earlier success or failure cannot replace a newer roster.
14
- const list = useFetchQuery({ key: listKey, fetch: () => port.listMembers({}) });
25
+ const list = useFetchQuery(listRequest);
15
26
  const members = list.data?.members ?? null;
16
- const error = list.error ? describeError(list.error, translate("failed to load members")) : null;
17
- const listLoader = useCachedLoader({ key: listKey, fetch: () => port.listMembers({}) }, { staleTime: 0 });
27
+ const error = describeIfError(list.error, translate("failed to load members"));
28
+ const listLoader = useCachedLoader(listRequest, { staleTime: 0 });
18
29
  const disable = useFetchMutation({ run: ({ input: id }) => port.disableMember({ id }) }, { invalidates: [listKey, detailRoot] });
19
30
  // Sending a link changes no list/detail fields; do not refetch the roster on success.
20
31
  const resend = useFetchMutation({ run: ({ input: email }) => port.requestMemberMagicLink({ email }) });
@@ -33,11 +44,9 @@ export function useMembers({ port, translate, queryScope = "members" }, { refres
33
44
  const [detailGeneration, setDetailGeneration] = useState(0);
34
45
  // Entity keys own settlement: a late A response cannot paint B's error/spinner. Detail
35
46
  // data remains cached on collapse, while successful disables invalidate both resources.
36
- const detail = useFetchQuery({
37
- key: [...detailRoot, expandedId ?? '', detailGeneration],
38
- fetch: () => port.getMember({ id: expandedId }),
39
- }, { enabled: expandedId !== null, staleTime: Infinity });
40
- const detailLoader = useCachedLoader({ key: [...detailRoot, expandedId ?? '', detailGeneration], fetch: () => port.getMember({ id: expandedId }) }, { staleTime: Infinity });
47
+ const detailRequest = { key: [...detailRoot, expandedId ?? '', detailGeneration], fetch: () => port.getMember({ id: expandedId }) };
48
+ const detail = useFetchQuery(detailRequest, { enabled: expandedId !== null, staleTime: Infinity });
49
+ const detailLoader = useCachedLoader(detailRequest, { staleTime: Infinity });
41
50
  useEffect(() => {
42
51
  expandedIdRef.current = null;
43
52
  detailGenerations.current = {};
@@ -49,10 +58,10 @@ export function useMembers({ port, translate, queryScope = "members" }, { refres
49
58
  }, [queryScope]);
50
59
  // Only paint the failure onto the panel that is still expanded: the observer's entity key
51
60
  // selects the current row, so a late rejection for a collapsed row cannot surface here.
52
- const detailError = detail.error ? describeError(detail.error, translate("Failed to load member detail.")) : null;
61
+ const detailError = describeIfError(detail.error, translate("Failed to load member detail."));
53
62
  // A superseded load leaves the spinner to its successor. Derive it from the current keyed
54
63
  // observer instead of letting an old request's finally clear the new row's loading state.
55
- const detailLoadingId = expandedId !== null && !detail.data && detail.isFetching ? expandedId : null;
64
+ const detailLoadingId = detailLoadingIdFor({ expandedId, detail });
56
65
  useEffect(() => {
57
66
  if (expandedId && detail.data)
58
67
  setDetailById(current => ({ ...current, [expandedId]: detail.data.member }));
@@ -141,7 +150,7 @@ export function useMembers({ port, translate, queryScope = "members" }, { refres
141
150
  confirmingDisable,
142
151
  setConfirmingDisable,
143
152
  confirmDisable,
144
- t: boundT,
153
+ t: translate,
145
154
  translate,
146
155
  };
147
156
  }
@@ -9,9 +9,9 @@ export function createFakeLoginPort(_required, options = {}) {
9
9
  };
10
10
  }
11
11
  const FAKE_WORKSPACE_ID = "fake-ws";
12
- function fakeUser(overrides = {}) {
12
+ // Every caller mints a fresh id, so the id is required rather than defaulted.
13
+ function fakeUser(overrides) {
13
14
  return {
14
- principalId: overrides.principalId ?? "fake-user-1",
15
15
  workspaceId: FAKE_WORKSPACE_ID,
16
16
  username: "fake",
17
17
  status: "active",
@@ -108,18 +108,16 @@ export function createFakeUsersPort(_required, options = {}) {
108
108
  },
109
109
  };
110
110
  }
111
- function fakeRole(overrides = {}) {
111
+ function fakeRole(overrides) {
112
112
  return {
113
- id: overrides.id ?? "fake-role-1",
114
113
  workspaceId: FAKE_WORKSPACE_ID,
115
114
  name: "Fake role",
116
115
  isBuiltin: false,
117
116
  ...overrides,
118
117
  };
119
118
  }
120
- function fakePolicy(overrides = {}) {
119
+ function fakePolicy(overrides) {
121
120
  return {
122
- id: overrides.id ?? "fake-policy-1",
123
121
  workspaceId: FAKE_WORKSPACE_ID,
124
122
  name: "Fake policy",
125
123
  isBuiltin: false,