@jini-ai/user-management 0.3.1 → 0.3.2

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.
@@ -5,4 +5,24 @@ export interface SessionTokenPort {
5
5
  rawToken: string;
6
6
  }): string;
7
7
  }
8
+ /** Host lifecycle for renames. Audit writes must join the identity transaction. */
9
+ export interface UsernameChangePort {
10
+ /** A host can refuse a rename that its bootstrap identity cannot safely survive. */
11
+ assertSupported?: ((required: {
12
+ workspaceId: string;
13
+ principalId: string;
14
+ }, optional?: Record<string, never>) => Promise<void>) | undefined;
15
+ isInTrash(required: {
16
+ workspaceId: string;
17
+ principalId: string;
18
+ }, optional?: Record<string, never>): Promise<boolean>;
19
+ record(required: {
20
+ workspaceId: string;
21
+ principalId: string;
22
+ callerPrincipalId: string;
23
+ previousUsername: string;
24
+ username: string;
25
+ occurredAt: string;
26
+ }, optional?: Record<string, never>): Promise<void>;
27
+ }
8
28
  //# sourceMappingURL=runtime-ports.d.ts.map
@@ -156,8 +156,11 @@ export declare class IdentityNotFoundError extends Error {
156
156
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
157
157
  */
158
158
  export declare class IdentityConflictError extends Error {
159
- constructor({ message }: {
159
+ /** Rename conflicts carry a precise reason; legacy conflicts retain their existing contract. */
160
+ readonly code?: "RESOURCE_CONFLICT" | "USER_IN_TRASH" | "USERNAME_IN_TRASH";
161
+ constructor({ message, code }: {
160
162
  message: string;
163
+ code?: "RESOURCE_CONFLICT" | "USER_IN_TRASH" | "USERNAME_IN_TRASH";
161
164
  }, optional?: ErrorOptions);
162
165
  }
163
166
  /** Raised by `login` on bad credentials, a disabled principal, or a blank field. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -18,8 +18,12 @@ export class IdentityNotFoundError extends Error {
18
18
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
19
19
  */
20
20
  export class IdentityConflictError extends Error {
21
- constructor({ message }, optional = {}) {
21
+ /** Rename conflicts carry a precise reason; legacy conflicts retain their existing contract. */
22
+ code;
23
+ constructor({ message, code }, optional = {}) {
22
24
  super(message, optional);
25
+ if (code !== undefined)
26
+ this.code = code;
23
27
  }
24
28
  }
25
29
  /** Raised by `login` on bad credentials, a disabled principal, or a blank field. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -15,6 +15,9 @@ export type { MembersProps } from './members/Members.js';
15
15
  export { useMembers } from './members/hooks/use-members.hooks.js';
16
16
  export type { MembersController, MembersDependencies, MembersOptions } from './members/hooks/use-members.hooks.js';
17
17
  export { describeIdentityError } from './errors.js';
18
+ export { useChangeUsername } from './users/hooks/use-change-username.hooks.js';
19
+ export type { ChangeUsernameController } from './users/hooks/use-change-username.hooks.js';
20
+ export { ChangeUsernameDialog } from './users/ChangeUsernameDialog.js';
18
21
  export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
19
22
  export type { UserAccountCapabilities, UserRowMenuHandlers } from './users/rules.js';
20
23
  export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
@@ -6,6 +6,8 @@ export { useResetPasswordFields } from './users/hooks/use-reset-password-fields.
6
6
  export { Members } from './members/Members.js';
7
7
  export { useMembers } from './members/hooks/use-members.hooks.js';
8
8
  export { describeIdentityError } from './errors.js';
9
+ export { useChangeUsername } from './users/hooks/use-change-username.hooks.js';
10
+ export { ChangeUsernameDialog } from './users/ChangeUsernameDialog.js';
9
11
  export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
10
12
  export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
11
13
  //# sourceMappingURL=index.js.map
@@ -29,5 +29,5 @@ export interface MembersProps extends MembersDependencies, MembersOptions {
29
29
  value: string;
30
30
  }) => ReactNode) | undefined;
31
31
  }
32
- export declare function Members({ useMembersHook, port, translate: injectedTranslate, refresh, description, emptyDescription, renderServerLabel }: MembersProps): import("react").JSX.Element;
32
+ export declare function Members({ useMembersHook, port, translate: injectedTranslate, queryScope, refresh, description, emptyDescription, renderServerLabel }: MembersProps): import("react").JSX.Element;
33
33
  //# sourceMappingURL=Members.d.ts.map
@@ -28,8 +28,8 @@ function MemberRow({ member, rowState, isExpanded, detail, detailError, detailLo
28
28
  onRequestDisable: setConfirmingDisable,
29
29
  }, translate }) }), rowState.error ? (_jsx("div", { className: "notice error", role: "alert", children: rowState.error })) : null, rowState.notice ? _jsx("div", { className: "notice", children: rowState.notice }) : null] })] }), isExpanded ? (_jsx("tr", { children: _jsx("td", { colSpan: 5, children: _jsx(MemberDetailPanel, { memberId: member.id, detailLoadingId: detailLoadingId, detailError: detailError, detail: detail, t: t }) }) })) : null] }, member.id));
30
30
  }
31
- export function Members({ useMembersHook = useMembers, port, translate: injectedTranslate, refresh, description = "People who have registered an account — review status, resend a sign-in link, or disable access.", emptyDescription = "Registered members will show up here.", renderServerLabel }) {
32
- const { members, error, stateFor, onResendSignInLink, expandedId, detailById, detailError, detailLoadingId, onToggleDetail, confirmingDisable, setConfirmingDisable, confirmDisable, t, translate, } = useMembersHook({ port, translate: injectedTranslate }, { refresh });
31
+ export function Members({ useMembersHook = useMembers, port, translate: injectedTranslate, queryScope, refresh, description = "People who have registered an account — review status, resend a sign-in link, or disable access.", emptyDescription = "Registered members will show up here.", renderServerLabel }) {
32
+ const { members, error, stateFor, onResendSignInLink, expandedId, detailById, detailError, detailLoadingId, onToggleDetail, confirmingDisable, setConfirmingDisable, confirmDisable, t, translate, } = useMembersHook({ port, translate: injectedTranslate, ...(queryScope === undefined ? {} : { queryScope }) }, { refresh });
33
33
  if (error)
34
34
  return _jsx("div", { className: "notice error", children: error });
35
35
  if (!members)
@@ -34,6 +34,8 @@ import type { Translate } from "@jini-ai/ui/panel-kit";
34
34
  */
35
35
  export interface MembersDependencies {
36
36
  port: MembersPort;
37
+ /** Same session/workspace scope as Users; isolated providers retain legacy composition. */
38
+ queryScope?: string;
37
39
  translate: Translate;
38
40
  }
39
41
  export interface MembersController {
@@ -70,5 +72,5 @@ export interface MembersController {
70
72
  export interface MembersOptions {
71
73
  refresh?: IdentityRefreshPort | undefined;
72
74
  }
73
- export declare function useMembers({ port, translate }: MembersDependencies, { refresh }?: MembersOptions): MembersController;
75
+ export declare function useMembers({ port, translate, queryScope }: MembersDependencies, { refresh }?: MembersOptions): MembersController;
74
76
  //# sourceMappingURL=use-members.hooks.d.ts.map
@@ -1,60 +1,66 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { describeApiError, emptyRowState } from "../rules.js";
3
- import { useSettlementGeneration } from "@jini-ai/ui/panel-kit";
4
- export function useMembers({ port, translate }, { refresh } = {}) {
3
+ import { useCachedLoader, useFetchMutation, useFetchQuery, useInvalidate } from "@jini-ai/ui/fetch-query";
4
+ export function useMembers({ port, translate, queryScope = "members" }, { refresh } = {}) {
5
+ const scopeRef = useRef(queryScope);
6
+ scopeRef.current = queryScope;
5
7
  const boundT = translate;
6
8
  const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
7
- const [members, setMembers] = useState(null);
8
- const [error, setError] = useState(null);
9
+ const listKey = [queryScope, 'members', 'list'];
10
+ const detailRoot = [queryScope, 'members', 'detail'];
11
+ const invalidate = useInvalidate();
12
+ // Mount reads and assistant refreshes can overlap; the shared cache owns list settlement
13
+ // order so a slow earlier success or failure cannot replace a newer roster.
14
+ const list = useFetchQuery({ key: listKey, fetch: () => port.listMembers({}) });
15
+ 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 });
18
+ const disable = useFetchMutation({ run: ({ input: id }) => port.disableMember({ id }) }, { invalidates: [listKey, detailRoot] });
19
+ // Sending a link changes no list/detail fields; do not refetch the roster on success.
20
+ const resend = useFetchMutation({ run: ({ input: email }) => port.requestMemberMagicLink({ email }) });
9
21
  const [rowState, setRowState] = useState({});
10
22
  const [expandedId, setExpandedId] = useState(null);
11
23
  const [detailById, setDetailById] = useState({});
12
- const [detailError, setDetailError] = useState(null);
13
- const [detailLoadingId, setDetailLoadingId] = useState(null);
14
24
  // Disable now confirms via a `RowMenu` item -> `ConfirmDialog` modal (replacing the in-place
15
25
  // two-click `ConfirmButton`, which has no menu-item equivalent — same migration Posts.tsx/
16
26
  // Redirects.tsx/Users.tsx already made). `null` when the dialog is closed.
17
27
  const [confirmingDisable, setConfirmingDisable] = useState(null);
18
- const settlement = useSettlementGeneration();
19
28
  // Which row's detail panel is actually open (C7, plan-access.md §8, N2) — a ref, not just
20
- // `expandedId` state, because `onToggleDetail`'s `catch`/`finally` read it after an `await`, where
21
- // a state read would see the closure's stale value. Set by `onToggleDetail` alone. Without this,
22
- // expanding row A (slow, then failing), then row B, lets A's `finally` blank B's loading indicator
23
- // and lets A's failure paint onto B's now-open panel — the same "entity-scoped panel, unkeyed
24
- // settle" shape `use-roles.hooks.ts`'s `permissionPolicyIdRef` fixes for `loadPermissions`.
29
+ // expandedId state, because onToggleDetail reads it after await, when a closure can be stale.
30
+ // Expanding slow/failing A then B must not let A's failure or finally blank B's panel/spinner.
25
31
  const expandedIdRef = useRef(null);
26
- // Latest-wins for `onToggleDetail`'s detail loads, a separate instance from `settlement` (which
27
- // tracks `load()`'s list reads).
28
- const detailSettlement = useSettlementGeneration();
29
- const load = useCallback(() => {
30
- // Claim this call's generation BEFORE the request starts — see `useSettlementGeneration`'s own
31
- // doc for why a synchronous ref bump, not `useState`, is what makes two overlapping calls each
32
- // see the other's claim. Needed now that a content refresh can fire more than once per run
33
- // (mid-run tool progress, see `AssistantDock.hooks.tsx`), so two overlapping `load()` calls have
34
- // no ordering guarantee on their responses.
35
- const generation = settlement.next();
36
- port
37
- .listMembers({})
38
- .then((r) => {
39
- if (!settlement.isCurrent({ generation }))
40
- return;
41
- setMembers(r.members);
42
- setError(null);
43
- })
44
- .catch((e) => {
45
- if (!settlement.isCurrent({ generation }))
46
- return;
47
- setError(e instanceof Error ? e.message : translate("failed to load members"));
48
- });
49
- // `port`/`locale`/`settlement` are added — see `use-page-editor.hooks.ts`'s identical note:
50
- // function-scoped values ESLint's exhaustive-deps rule can see, referentially stable in
51
- // production, so this changes nothing about when this callback's identity changes.
52
- // eslint-disable-next-line react-hooks/exhaustive-deps
53
- }, [port, settlement, translate]);
32
+ const detailGenerations = useRef({});
33
+ const [detailGeneration, setDetailGeneration] = useState(0);
34
+ // Entity keys own settlement: a late A response cannot paint B's error/spinner. Detail
35
+ // 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 });
54
41
  useEffect(() => {
55
- load();
56
- }, [load]);
57
- useEffect(() => refresh?.subscribe({ onRefresh: load }), [refresh, load]);
42
+ expandedIdRef.current = null;
43
+ detailGenerations.current = {};
44
+ setExpandedId(null);
45
+ setDetailGeneration(0);
46
+ setDetailById({});
47
+ setRowState({});
48
+ setConfirmingDisable(null);
49
+ }, [queryScope]);
50
+ // Only paint the failure onto the panel that is still expanded: the observer's entity key
51
+ // 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;
53
+ // A superseded load leaves the spinner to its successor. Derive it from the current keyed
54
+ // 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;
56
+ useEffect(() => {
57
+ if (expandedId && detail.data)
58
+ setDetailById(current => ({ ...current, [expandedId]: detail.data.member }));
59
+ }, [expandedId, detail.data]);
60
+ useEffect(() => refresh?.subscribe({ onRefresh: () => {
61
+ invalidate({ key: listKey });
62
+ invalidate({ key: detailRoot });
63
+ } }), [refresh, invalidate, queryScope]);
58
64
  function stateFor({ id }) {
59
65
  return rowState[id] ?? emptyRowState({});
60
66
  }
@@ -66,8 +72,9 @@ export function useMembers({ port, translate }, { refresh } = {}) {
66
72
  return;
67
73
  patchRowState(member.id, { disabling: true, error: null, notice: null });
68
74
  try {
69
- const result = await port.disableMember({ id: member.id });
70
- setMembers((current) => (current ? current.map((m) => (m.id === member.id ? result.member : m)) : current));
75
+ const result = await disable.mutate({ input: member.id });
76
+ await listLoader.load().catch(() => { }); // Await roster settlement; list.error owns a failed read, not the accepted disable.
77
+ setDetailById(current => current[member.id] ? { ...current, [member.id]: result.member } : current);
71
78
  patchRowState(member.id, { disabling: false, notice: translate("Member disabled.") });
72
79
  }
73
80
  catch (e) {
@@ -79,7 +86,7 @@ export function useMembers({ port, translate }, { refresh } = {}) {
79
86
  return;
80
87
  patchRowState(member.id, { resending: true, error: null, notice: null });
81
88
  try {
82
- await port.requestMemberMagicLink({ email: member.email });
89
+ await resend.mutate({ input: member.email });
83
90
  patchRowState(member.id, { resending: false, notice: translate("Sign-in link sent.") });
84
91
  }
85
92
  catch (e) {
@@ -103,30 +110,23 @@ export function useMembers({ port, translate }, { refresh } = {}) {
103
110
  }
104
111
  expandedIdRef.current = member.id;
105
112
  setExpandedId(member.id);
106
- setDetailError(null);
107
- if (detailById[member.id])
108
- return;
109
- setDetailLoadingId(member.id);
110
- // Claimed before the `await`, so a later detail load (another row's, or this same row's after
111
- // a close and reopen) supersedes this one. The row-id key alone cannot tell two loads of the
112
- // SAME row apart.
113
- const generation = detailSettlement.next();
113
+ // A collapsed unfinished request cannot own a reopened panel. A primitive generation
114
+ // creates a new read fence for that member, preserving the prior latest-wins contract;
115
+ // successful details keep their current generation so reopen reuses the cache.
116
+ // Claimed before await: even two loads of the SAME row must be distinguishable, since a
117
+ // row-id key alone cannot fence an unfinished request after collapse/reopen.
118
+ const generation = detailById[member.id] ? detailGenerations.current[member.id] ?? 1 : (detailGenerations.current[member.id] ?? 0) + 1;
119
+ detailGenerations.current[member.id] = generation;
120
+ setDetailGeneration(generation);
121
+ // Start through the same cache synchronously so imperative callers still await the
122
+ // detail read; the reactive observer joins it when React commits the selected identity.
123
+ const requestedScope = queryScope;
114
124
  try {
115
- const result = await port.getMember({ id: member.id });
116
- setDetailById((current) => ({ ...current, [member.id]: result.member }));
117
- }
118
- catch (e) {
119
- // Only paint the failure onto the panel this load was actually for — a different row may
120
- // already be open by the time this settles, or a newer load of this row may be running.
121
- if (detailSettlement.isCurrent({ generation }) && expandedIdRef.current === member.id) {
122
- setDetailError(describeError(e, translate("Failed to load member detail.")));
123
- }
124
- }
125
- finally {
126
- // A superseded load leaves the spinner to the load that superseded it.
127
- if (detailSettlement.isCurrent({ generation }))
128
- setDetailLoadingId(null);
125
+ const result = await detailLoader.load({ key: [...detailRoot, member.id, generation], fetch: () => port.getMember({ id: member.id }) });
126
+ if (scopeRef.current === requestedScope && detailGenerations.current[member.id] === generation)
127
+ setDetailById(current => ({ ...current, [member.id]: result.member }));
129
128
  }
129
+ catch { /* The keyed observer owns the failure for the panel that is still expanded. */ }
130
130
  }
131
131
  return {
132
132
  members,
@@ -14,6 +14,8 @@ export interface AdminIdentityUser {
14
14
  /** Server classification covers ownership granted by policy, not only named roles. */
15
15
  isOwner?: boolean | undefined;
16
16
  isProtectedAccount?: boolean | undefined;
17
+ /** Host refusal for accounts whose bootstrap identity cannot safely follow a rename yet. */
18
+ canRename?: boolean | undefined;
17
19
  roleIds: string[];
18
20
  policyIds: string[];
19
21
  }
@@ -16,6 +16,7 @@ export interface UsersPort {
16
16
  id: string;
17
17
  };
18
18
  canManageUserTrash: boolean;
19
+ effectivePermissions?: string[] | undefined;
19
20
  }>;
20
21
  listRoles(required: Record<string, never>): Promise<{
21
22
  roles: AdminRole[];
@@ -35,6 +36,7 @@ export interface UsersPort {
35
36
  principalId: string;
36
37
  }, options?: {
37
38
  email?: string | undefined;
39
+ username?: string | undefined;
38
40
  }): Promise<{
39
41
  user: AdminIdentityUser;
40
42
  }>;
@@ -67,7 +67,7 @@ export function createFakeUsersPort(_required, options = {}) {
67
67
  if (options.updateUserError)
68
68
  throw options.updateUserError;
69
69
  const current = requireUser(target.principalId);
70
- return { user: replaceUser({ ...current, ...opts }) };
70
+ return { user: replaceUser({ ...current, ...opts, username: opts?.username ?? current.username }) };
71
71
  },
72
72
  async disableUser({ principalId }) {
73
73
  if (options.toggleStatusError)
@@ -0,0 +1,8 @@
1
+ import type { Translate } from "@jini-ai/ui/panel-kit";
2
+ import type { useChangeUsername } from "./hooks/use-change-username.hooks.js";
3
+ /** Markup shared by row actions and the signed-in user's profile; state lives in the hook. */
4
+ export declare function ChangeUsernameDialog({ controller, translate: t }: {
5
+ controller: ReturnType<typeof useChangeUsername>;
6
+ translate: Translate;
7
+ }, _optional?: Record<string, never>): import("react").JSX.Element;
8
+ //# sourceMappingURL=ChangeUsernameDialog.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ConfirmDialog } from "@jini-ai/admin/react";
3
+ import { agentHandle } from "@jini-ai/agentic";
4
+ /** Markup shared by row actions and the signed-in user's profile; state lives in the hook. */
5
+ export function ChangeUsernameDialog({ controller, translate: t }, _optional = {}) {
6
+ return _jsx(ConfirmDialog, { open: controller.target !== null, agentHandle: "users-change-username", title: t("Change username"), cancelLabel: t("Cancel"), confirmLabel: t("Change username"), pending: controller.pending, onConfirm: () => void controller.confirm({}), onCancel: () => controller.cancel({}), body: _jsxs(_Fragment, { children: [_jsx("p", { children: controller.target?.username }), _jsxs("label", { children: [t("New username"), _jsx("input", { value: controller.username, autoComplete: "off", disabled: controller.pending, onChange: event => controller.setUsername(event.target.value), ...agentHandle({ handle: "users-change-username-input" }, { role: "field", label: "New login username" }) })] }), _jsx("p", { children: t("Existing sessions stay signed in. Use the new username the next time you sign in.") }), controller.error ? _jsx("p", { role: "alert", className: "save-error", children: controller.error }) : null] }) });
7
+ }
8
+ //# sourceMappingURL=ChangeUsernameDialog.js.map
@@ -1,7 +1,7 @@
1
1
  import { type Dispatch, type SetStateAction } from "react";
2
2
  import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../models.js";
3
3
  import { type UserAccountCapabilities } from "./rules.js";
4
- import { useUsers, type UsersDependencies, type UsersOptions } from "./hooks/use-users.hooks.js";
4
+ import { useUsers, type UsersController, type UsersDependencies, type UsersOptions } from "./hooks/use-users.hooks.js";
5
5
  import type { ReactNode } from "react";
6
6
  import type { Translate } from "@jini-ai/ui/panel-kit";
7
7
  /**
@@ -130,6 +130,7 @@ export interface UserRowActionsController {
130
130
  requestDisable: (user: AdminIdentityUser) => void;
131
131
  toggleStatus: (user: AdminIdentityUser) => Promise<void>;
132
132
  openResetPassword: (user: AdminIdentityUser) => void;
133
+ renameUserHandler?: UsersController["renameUserHandler"];
133
134
  /** Delete-user plan v2 (2026-09-24): whether the row's `RowMenu` should carry a "Delete" item at
134
135
  * all — see `use-users.hooks.ts`'s `canManageUserTrash` doc comment for where this comes from. */
135
136
  canDelete: boolean;
@@ -7,6 +7,7 @@ import { formatGrantLabel, grantOptionLabel, userRowMenuItems, isOwnerAccount }
7
7
  import { useUsers } from "./hooks/use-users.hooks.js";
8
8
  import { InfoTip } from "@jini-ai/ui/admin-widgets";
9
9
  import { useResetPasswordFields } from "./hooks/use-reset-password-fields.hooks.js";
10
+ import { ChangeUsernameDialog } from "./ChangeUsernameDialog.js";
10
11
  /** The "New user" form — extracted from `Users` verbatim; own scope for `formError`/`saving`. */
11
12
  function NewUserForm({ username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, t }) {
12
13
  return (_jsxs("form", { onSubmit: onCreate, className: "notice integrations-form form-measure", children: [formError ? _jsx("span", { className: "save-error", children: formError }) : null, _jsxs("label", { children: [t("Username"), _jsx("input", { value: username, onChange: (e) => setUsername(e.target.value), required: true, ...agentHandle({ handle: "users-new-username" }, { role: "field", label: "The new user's username" }) })] }), _jsxs("label", { children: [t("Email (optional)"), _jsx("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), ...agentHandle({ handle: "users-new-email" }, { role: "field", label: "The new user's email address" }) })] }), _jsxs("label", { children: [t("Password"), _jsx("input", { type: "password", autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 1, ...agentHandle({ handle: "users-new-password" }, {
@@ -43,6 +44,7 @@ function UserRow({ user, roleById, policyById, showGrants, actions, manage, agen
43
44
  onEnable: (u) => void actions.toggleStatus(u),
44
45
  onManage: actions.toggleExpanded,
45
46
  onResetPassword: actions.openResetPassword,
47
+ onChangeUsername: actions.renameUserHandler?.({ user }),
46
48
  onRequestDelete: actions.requestDelete,
47
49
  }, translate, canDelete: actions.canDelete,
48
50
  capabilities: actions.rowCapabilities?.(user) ?? actions.canDelete,
@@ -159,20 +161,21 @@ function UsersNotices({ toggleError, notice }) {
159
161
  return (_jsxs(_Fragment, { children: [toggleError ? _jsx("div", { className: "notice error", children: toggleError }) : null, notice ? _jsx("div", { className: "notice", children: notice }) : null] }));
160
162
  }
161
163
  export function Users({ useUsersHook = useUsers, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }) {
162
- const { users, roles, policies, canGrant, error, formOpen, setFormOpen, username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, expandedId, toggleExpanded, pendingRoleId, setPendingRoleId, pendingPolicyId, setPendingPolicyId, grantSaving, grantError, onAssignRole, onAttachPolicy, editEmail, setEditEmail, emailSaving, onSaveEmail, toggleSavingId, toggleError, notice, confirmingDisable, setConfirmingDisable, requestDisable, confirmDisable, onToggleStatus, resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordSaving, passwordError, setPasswordError, openResetPassword, confirmResetPassword, canManageUserTrash, rowCapabilities, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = useUsersHook({ port, translate: injectedTranslate, queryScope }, {
164
+ const { users, roles, policies, canGrant, error, formOpen, setFormOpen, username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, expandedId, toggleExpanded, pendingRoleId, setPendingRoleId, pendingPolicyId, setPendingPolicyId, grantSaving, grantError, onAssignRole, onAttachPolicy, editEmail, setEditEmail, emailSaving, onSaveEmail, toggleSavingId, toggleError, notice, confirmingDisable, setConfirmingDisable, requestDisable, confirmDisable, onToggleStatus, resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordSaving, passwordError, setPasswordError, openResetPassword, confirmResetPassword, canManageUserTrash, rowCapabilities, ownProfile, canRenameOwnProfile, openOwnRename, renameUserHandler, changeUsername, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = useUsersHook({ port, translate: injectedTranslate, queryScope }, {
163
165
  openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice,
164
166
  });
165
167
  if (error)
166
168
  return _jsx("div", { className: "notice error", children: error });
167
169
  if (!users || !roles || !policies)
168
170
  return _jsx("div", { className: "notice", children: t("Loading users…") });
169
- return (_jsxs("div", { className: "page", children: [_jsx(UsersPageHeader, { formOpen: formOpen, setFormOpen: setFormOpen, t: t }), formOpen ? (_jsx(NewUserForm, { username: username, setUsername: setUsername, email: email, setEmail: setEmail, password: password, setPassword: setPassword, saving: saving, formError: formError, onCreate: onCreate, t: t })) : null, _jsx(UsersNotices, { toggleError: toggleError, notice: notice }), _jsx(UsersTable, { users: users, roles: roles, policies: policies, showGrants: canGrant, actions: {
171
+ return (_jsxs("div", { className: "page", children: [_jsx(UsersPageHeader, { formOpen: formOpen, setFormOpen: setFormOpen, t: t }), ownProfile ? _jsxs("section", { className: "notice", "aria-label": t("My profile"), children: [_jsx("h2", { children: t("My profile") }), _jsx("p", { children: ownProfile.username }), canRenameOwnProfile ? _jsx("button", { type: "button", onClick: openOwnRename, ...agentHandle({ handle: "users-profile-change-username" }, { role: "button", label: "Change my login username" }), children: t("Change username") }) : null] }) : null, formOpen ? (_jsx(NewUserForm, { username: username, setUsername: setUsername, email: email, setEmail: setEmail, password: password, setPassword: setPassword, saving: saving, formError: formError, onCreate: onCreate, t: t })) : null, _jsx(UsersNotices, { toggleError: toggleError, notice: notice }), _jsx(UsersTable, { users: users, roles: roles, policies: policies, showGrants: canGrant, actions: {
170
172
  expandedId,
171
173
  toggleExpanded,
172
174
  savingId: toggleSavingId,
173
175
  requestDisable,
174
176
  toggleStatus: onToggleStatus,
175
177
  openResetPassword,
178
+ renameUserHandler,
176
179
  canDelete: canManageUserTrash,
177
180
  rowCapabilities,
178
181
  requestDelete,
@@ -186,6 +189,6 @@ export function Users({ useUsersHook = useUsers, port, translate: injectedTransl
186
189
  policyGrant: canGrant
187
190
  ? { options: policies, pendingId: pendingPolicyId, setPendingId: setPendingPolicyId, submit: onAttachPolicy }
188
191
  : null,
189
- }, t: t, translate: translate, renderServerLabel: renderServerLabel }), _jsx(UserDisableDialog, { confirmingDisable: confirmingDisable, setConfirmingDisable: setConfirmingDisable, toggleSavingId: toggleSavingId, confirmDisable: confirmDisable, t: t }), _jsx(UserDeleteDialog, { confirmingDelete: confirmingDelete, setConfirmingDelete: setConfirmingDelete, deleteSaving: deleteSaving, confirmDelete: confirmDelete, t: t }), _jsx(UserResetPasswordDialog, { resetPasswordFor: resetPasswordFor, setResetPasswordFor: setResetPasswordFor, newPassword: newPassword, setNewPassword: setNewPassword, passwordError: passwordError, setPasswordError: setPasswordError, passwordSaving: passwordSaving, confirmResetPassword: confirmResetPassword, t: t })] }));
192
+ }, t: t, translate: translate, renderServerLabel: renderServerLabel }), _jsx(UserDisableDialog, { confirmingDisable: confirmingDisable, setConfirmingDisable: setConfirmingDisable, toggleSavingId: toggleSavingId, confirmDisable: confirmDisable, t: t }), _jsx(UserDeleteDialog, { confirmingDelete: confirmingDelete, setConfirmingDelete: setConfirmingDelete, deleteSaving: deleteSaving, confirmDelete: confirmDelete, t: t }), _jsx(UserResetPasswordDialog, { resetPasswordFor: resetPasswordFor, setResetPasswordFor: setResetPasswordFor, newPassword: newPassword, setNewPassword: setNewPassword, passwordError: passwordError, setPasswordError: setPasswordError, passwordSaving: passwordSaving, confirmResetPassword: confirmResetPassword, t: t }), changeUsername ? _jsx(ChangeUsernameDialog, { controller: changeUsername, translate: t }) : null] }));
190
193
  }
191
194
  //# sourceMappingURL=Users.js.map
@@ -0,0 +1,27 @@
1
+ import { type Dispatch, type SetStateAction } from "react";
2
+ import { type Translate } from "@jini-ai/ui/panel-kit";
3
+ import type { AdminIdentityUser } from "../../models.js";
4
+ import type { UsersPort } from "../../ports.js";
5
+ export interface ChangeUsernameController {
6
+ target: AdminIdentityUser | null;
7
+ username: string;
8
+ setUsername: Dispatch<SetStateAction<string>>;
9
+ error: string | null;
10
+ pending: boolean;
11
+ open: (required: {
12
+ user: AdminIdentityUser;
13
+ }, optional?: Record<string, never>) => void;
14
+ cancel: (required?: Record<string, never>, optional?: Record<string, never>) => void;
15
+ confirm: (required?: Record<string, never>, optional?: Record<string, never>) => Promise<void>;
16
+ }
17
+ /** Shared rename dialog lifecycle: guard, write through the existing user port, invalidate list.
18
+ * Failures retain the target/draft and the server's actual reason; duplicate submits are refused.
19
+ * @complexity O(1) client state and one PATCH per submission. No credential/session effects.
20
+ */
21
+ export declare function useChangeUsername(required: {
22
+ port: Pick<UsersPort, "updateUser">;
23
+ queryScope: string;
24
+ translate: Translate;
25
+ canRename: (user: AdminIdentityUser) => boolean;
26
+ }, _optional?: Record<string, never>): ChangeUsernameController;
27
+ //# sourceMappingURL=use-change-username.hooks.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { useRef, useState } from "react";
2
+ import { useFetchMutation } from "@jini-ai/ui/panel-kit";
3
+ import { describeApiError, KEYS } from "../rules.js";
4
+ /** Shared rename dialog lifecycle: guard, write through the existing user port, invalidate list.
5
+ * Failures retain the target/draft and the server's actual reason; duplicate submits are refused.
6
+ * @complexity O(1) client state and one PATCH per submission. No credential/session effects.
7
+ */
8
+ export function useChangeUsername(required, _optional = {}) {
9
+ const [target, setTarget] = useState(null);
10
+ const [username, setUsername] = useState("");
11
+ const [error, setError] = useState(null);
12
+ const busy = useRef(false);
13
+ const mutation = useFetchMutation({
14
+ run: ({ input }) => required.port.updateUser({ principalId: input.principalId }, { username: input.username }),
15
+ }, { invalidates: [[required.queryScope, ...KEYS.list]] });
16
+ function open({ user }, _options = {}) {
17
+ if (busy.current || !required.canRename(user))
18
+ return;
19
+ setTarget(user);
20
+ setUsername(user.username);
21
+ setError(null);
22
+ }
23
+ function cancel(_input = {}, _options = {}) {
24
+ if (busy.current)
25
+ return;
26
+ setTarget(null);
27
+ setUsername("");
28
+ setError(null);
29
+ }
30
+ async function confirm(_input = {}, _options = {}) {
31
+ if (!target || busy.current || !required.canRename(target))
32
+ return;
33
+ if (!username.trim()) {
34
+ setError(required.translate("username must not be blank"));
35
+ return;
36
+ }
37
+ busy.current = true;
38
+ setError(null);
39
+ try {
40
+ await mutation.mutate({ input: { principalId: target.principalId, username } });
41
+ setTarget(null);
42
+ setUsername("");
43
+ }
44
+ catch (cause) {
45
+ setError(describeApiError({ error: cause, fallback: required.translate("failed to change username"), translate: required.translate }));
46
+ }
47
+ finally {
48
+ busy.current = false;
49
+ }
50
+ }
51
+ return { target, username, setUsername, error, pending: mutation.status === "pending", open, cancel, confirm };
52
+ }
53
+ //# sourceMappingURL=use-change-username.hooks.js.map
@@ -3,6 +3,7 @@ import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../../models.js"
3
3
  import { type Translate } from "@jini-ai/ui/panel-kit";
4
4
  import { type UserAccountCapabilities } from "../rules.js";
5
5
  import type { UsersPort } from "../../ports.js";
6
+ import { useChangeUsername } from "./use-change-username.hooks.js";
6
7
  /**
7
8
  * @file Everything the Users screen does, so `Users.tsx` is only markup.
8
9
  *
@@ -143,6 +144,14 @@ export interface UsersController {
143
144
  * the real boundary is server-side, not this flag. */
144
145
  canManageUserTrash: boolean;
145
146
  rowCapabilities: (user: AdminIdentityUser) => UserAccountCapabilities;
147
+ ownProfile?: AdminIdentityUser | null;
148
+ canRenameUser?: ((user: AdminIdentityUser) => boolean) | undefined;
149
+ canRenameOwnProfile?: boolean | undefined;
150
+ openOwnRename?: (() => void) | undefined;
151
+ renameUserHandler?: ((required: {
152
+ user: AdminIdentityUser;
153
+ }) => ((user: AdminIdentityUser) => void) | undefined) | undefined;
154
+ changeUsername?: ReturnType<typeof useChangeUsername> | undefined;
146
155
  /** The user a `RowMenu` "Delete" selection is asking to confirm; `null` when the dialog is shut.
147
156
  * Mirrors `confirmingDisable` exactly (own state, own request/confirm pair) rather than sharing
148
157
  * it — Delete and Disable are two independent confirms an operator could otherwise not tell apart
@@ -3,6 +3,7 @@ import { interpolate } from "@jini-ai/ui/panel-kit";
3
3
  import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/panel-kit";
4
4
  import { useAsyncAction } from "@jini-ai/ui/panel-kit";
5
5
  import { describeApiError, KEYS, isOwnerAccount, userAccountCapabilities } from "../rules.js";
6
+ import { useChangeUsername } from "./use-change-username.hooks.js";
6
7
  /** The shape `onAssignRole`/`onAttachPolicy` both repeat: set the shared `grantSaving`/`grantError`
7
8
  * pair, run one mutation, do a success-only side effect, and clear saving in a `finally` — the
8
9
  * "whole-hook" complexity view (brief §2) rolls every closure inside a hook into one score, so two
@@ -164,6 +165,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
164
165
  * one opened via `/users/change-password`. One extra `me()` call per screen load is cheaper than
165
166
  * threading two different notice code paths for what is, to the operator, the same event. */
166
167
  const [ownPrincipalId, setOwnPrincipalId] = useState(null);
168
+ const [effectivePermissions, setEffectivePermissions] = useState([]);
167
169
  // `canManageUserTrash` (delete-user plan v2, Slice 4): resolved by this SAME one-shot `me()` call
168
170
  // rather than a second request — see `UsersController.canManageUserTrash`'s own doc comment.
169
171
  const [canManageUserTrash, setCanManageUserTrash] = useState(false);
@@ -174,6 +176,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
174
176
  .then((res) => {
175
177
  if (!cancelled) {
176
178
  setOwnPrincipalId(res.user.id);
179
+ setEffectivePermissions(res.effectivePermissions ?? []);
177
180
  setCanManageUserTrash(res.canManageUserTrash);
178
181
  }
179
182
  })
@@ -199,6 +202,28 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
199
202
  callerIsOwner: ownRow ? isOwnerAccount({ user: ownRow, roles: roleMap }) : false,
200
203
  canManageUserTrash, roles: roleMap });
201
204
  }
205
+ const ownProfile = users?.find(user => user.principalId === ownPrincipalId) ?? null;
206
+ function canRenameUser(user) {
207
+ if (user.canRename === false)
208
+ return false;
209
+ if (!ownPrincipalId || (!effectivePermissions.includes("*") && !effectivePermissions.includes("user.manage")))
210
+ return false;
211
+ const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
212
+ const callerIsOwner = ownProfile ? isOwnerAccount({ user: ownProfile, roles: roleMap }) : false;
213
+ // Server-supplied owner flags remain authoritative when role.manage is unavailable.
214
+ const targetIsOwner = user.isOwner ?? (user.roleIds.some(id => !roleMap.has(id)) || isOwnerAccount({ user, roles: roleMap }));
215
+ return !targetIsOwner || callerIsOwner;
216
+ }
217
+ const changeUsername = useChangeUsername({ port, queryScope, translate, canRename: canRenameUser });
218
+ const canRenameOwnProfile = ownProfile !== null && canRenameUser(ownProfile);
219
+ /** Profile and row actions enter the same guarded dialog; the view only renders affordances. */
220
+ function openOwnRename() {
221
+ if (ownProfile)
222
+ changeUsername.open({ user: ownProfile });
223
+ }
224
+ function renameUserHandler({ user }, _options = {}) {
225
+ return canRenameUser(user) ? (target) => changeUsername.open({ user: target }) : undefined;
226
+ }
202
227
  const deepLinkAttemptedRef = useRef(false);
203
228
  /** Set true exactly when this effect opens the dialog, cleared by the close-effect below the first
204
229
  * time it observes `resetPasswordFor` go back to `null`. Read there to decide whether to navigate
@@ -443,6 +468,12 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
443
468
  confirmResetPassword,
444
469
  canManageUserTrash,
445
470
  rowCapabilities,
471
+ ownProfile,
472
+ canRenameUser,
473
+ canRenameOwnProfile,
474
+ openOwnRename,
475
+ renameUserHandler,
476
+ changeUsername,
446
477
  confirmingDelete,
447
478
  setConfirmingDelete,
448
479
  requestDelete,
@@ -47,6 +47,7 @@ export interface UserRowMenuHandlers {
47
47
  onEnable: (user: AdminIdentityUser) => void;
48
48
  onManage: (user: AdminIdentityUser) => void;
49
49
  onResetPassword: (user: AdminIdentityUser) => void;
50
+ onChangeUsername?: ((user: AdminIdentityUser) => void) | undefined;
50
51
  /** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
51
52
  * unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
52
53
  * it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
@@ -127,6 +127,9 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
127
127
  const permissions = typeof capabilities === "boolean"
128
128
  ? { canDelete: capabilities, canDisable: true, canEnable: true, canResetPassword: true }
129
129
  : capabilities;
130
+ if (handlers.onChangeUsername) {
131
+ items.push({ key: "change-username", label: translate("Change username"), onSelect: () => handlers.onChangeUsername?.(user) });
132
+ }
130
133
  if (permissions.canDelete) {
131
134
  items.push({
132
135
  key: "delete",
@@ -75,12 +75,13 @@ export declare function enablePrincipal(required: {
75
75
  }>;
76
76
  /**
77
77
  * `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
78
- * accept `member.manage`. Only owners may edit owner targets — `email` only; `username`/`password` are not
79
- * this transition's concern ( respectively). An absent or empty-string `email`
78
+ * accept `member.manage`. Username changes require `user.manage`, including self edits.
79
+ * Only owners may edit owner targets. Password is not this transition's concern.
80
+ * An absent or empty-string `email`
80
81
  * clears the stored value to `undefined` ( — no distinct "blank but present" state).
81
82
  *
82
- * @complexity O(1) — one lookup, one save.
83
- * @overallScore 100
83
+ * @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
84
+ * and one audit append for an actual rename, all inside the shared identity transaction.
84
85
  * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
85
86
  */
86
87
  export declare function updateUser(required: {
@@ -92,6 +93,7 @@ export declare function updateUser(required: {
92
93
  };
93
94
  }, optional?: {
94
95
  email?: string | undefined;
96
+ username?: string | undefined;
95
97
  }): Promise<{
96
98
  user: UserRecord;
97
99
  }>;
@@ -2,6 +2,7 @@ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
2
  import { inIdentityTransaction } from "./identity-transaction.js";
3
3
  import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
4
4
  import { validatePasswordPolicy } from "./password-policy.js";
5
+ import { normalizeUsername } from "./username.js";
5
6
  import { isKnownPermission } from "../core/permissions.js";
6
7
  import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
7
8
  import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
@@ -138,12 +139,13 @@ export async function enablePrincipal(required) {
138
139
  }
139
140
  /**
140
141
  * `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
141
- * accept `member.manage`. Only owners may edit owner targets — `email` only; `username`/`password` are not
142
- * this transition's concern ( respectively). An absent or empty-string `email`
142
+ * accept `member.manage`. Username changes require `user.manage`, including self edits.
143
+ * Only owners may edit owner targets. Password is not this transition's concern.
144
+ * An absent or empty-string `email`
143
145
  * clears the stored value to `undefined` ( — no distinct "blank but present" state).
144
146
  *
145
- * @complexity O(1) — one lookup, one save.
146
- * @overallScore 100
147
+ * @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
148
+ * and one audit append for an actual rename, all inside the shared identity transaction.
147
149
  * See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
148
150
  */
149
151
  export async function updateUser(required, optional = {}) {
@@ -158,7 +160,7 @@ export async function updateUser(required, optional = {}) {
158
160
  workspaceId: input.workspaceId,
159
161
  callerPrincipalId: input.callerPrincipalId,
160
162
  // Preserve existing self-profile access without letting member.manage edit other operators.
161
- permissions: input.callerPrincipalId === input.principalId
163
+ permissions: input.username === undefined && input.callerPrincipalId === input.principalId
162
164
  ? ["user.manage", "member.manage"]
163
165
  : ["user.manage"],
164
166
  });
@@ -174,12 +176,53 @@ export async function updateUser(required, optional = {}) {
174
176
  callerPrincipalId: input.callerPrincipalId,
175
177
  principalId: input.principalId,
176
178
  });
177
- const updated = { ...target, email: input.email ? input.email : undefined };
179
+ const username = input.username === undefined ? target.username : await resolveUpdatedUsername({ deps, input, target });
180
+ // Preserve the old email setter contract, while a username-only call retains email.
181
+ const email = input.username !== undefined && !Object.hasOwn(optional, "email")
182
+ ? target.email : (input.email || undefined);
183
+ const updated = { ...target, username, email };
178
184
  await deps.repos.users.save(updated);
185
+ if (username !== target.username) {
186
+ // Renaming never changes the principal id or credentials: existing sessions stay valid.
187
+ await deps.usernameChanges.record({ workspaceId: input.workspaceId, principalId: input.principalId,
188
+ callerPrincipalId: input.callerPrincipalId, previousUsername: target.username, username,
189
+ occurredAt: kernelNowIso({ clock: deps.clock }) });
190
+ }
179
191
  return { user: updated };
180
192
  },
181
193
  });
182
194
  }
195
+ /** Validate and reserve a normalized name inside the caller's identity transaction.
196
+ * Permission/owner gates run first, so conflicts never disclose accounts to unauthorized callers.
197
+ * @complexity O(n) normalization for n username characters; one indexed lookup and up to two Trash reads.
198
+ * Throws validation or conflict errors; performs no writes.
199
+ */
200
+ async function resolveUpdatedUsername(required, _optional = {}) {
201
+ const { deps, input, target } = required;
202
+ if (typeof input.username !== "string")
203
+ throw new IdentityValidationError({ message: "username must be a string" });
204
+ const username = normalizeUsername({ raw: input.username });
205
+ if (!username)
206
+ throw new IdentityValidationError({ message: "username must not be blank" });
207
+ // Missing host ports are internal wiring faults, not user input errors (HTTP 500, not 400).
208
+ if (!deps.usernameChanges)
209
+ throw new Error("username changes are not configured");
210
+ if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: input.principalId })) {
211
+ throw new IdentityConflictError({ message: "this user is in the Trash; restore them first", code: "USER_IN_TRASH" });
212
+ }
213
+ if (username !== target.username) {
214
+ await deps.usernameChanges.assertSupported?.({ workspaceId: input.workspaceId, principalId: input.principalId });
215
+ }
216
+ // Trash keeps credential rows until purge, so the unfiltered lookup reserves those names too.
217
+ const existing = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
218
+ if (existing && existing.principalId !== target.principalId) {
219
+ if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: existing.principalId })) {
220
+ throw new IdentityConflictError({ message: "a user with this username is in the Trash; restore or delete them permanently first", code: "USERNAME_IN_TRASH" });
221
+ }
222
+ throw new IdentityConflictError({ message: `username '${username}' is already in use`, code: "RESOURCE_CONFLICT" });
223
+ }
224
+ return username;
225
+ }
183
226
  /**
184
227
  * `RESET_USER_PASSWORD` (feature.spec.md ). Gated by **`user.manage`** only
185
228
  * (resetting a credential is at least as sensitive as disabling the account, own reasoning). Hashes the new password identically to `CREATE_USER` (argon2id,
@@ -40,6 +40,7 @@ export interface IdentityToolDeps {
40
40
  /** Bind the same transaction port as every grant/status writer. */
41
41
  transactions: IdentityRepos["transactions"];
42
42
  tokens: AuthServiceDeps["tokens"];
43
+ usernameChanges?: AuthServiceDeps["usernameChanges"];
43
44
  }
44
45
  /**
45
46
  * This wiring layer's OWN risk classification, authored from what each handler below actually
@@ -48,6 +48,7 @@ function identityServiceDepsFrom(deps) {
48
48
  clock: deps.clock,
49
49
  idGen: deps.idGen,
50
50
  tokens: deps.tokens,
51
+ usernameChanges: deps.usernameChanges,
51
52
  };
52
53
  }
53
54
  /**
@@ -64,6 +65,8 @@ export const identityDerivedRisk = new Map([
64
65
  ["identity_user_create", "mutates-durable-state"],
65
66
  // -> updateUser (admin-crud-service.ts): users.save, email field only.
66
67
  ["identity_user_update_email", "mutates-durable-state"],
68
+ // -> updateUser (admin-crud-service.ts): users.save, username field only, plus the transactional audit.
69
+ ["identity_user_change_username", "mutates-durable-state"],
67
70
  // -> disablePrincipal (admin-crud-service.ts): status flip, guarded by the owner floor. See docs/decisions/DR-007-workspace-and-owner-floors.md.
68
71
  ["identity_user_disable", "mutates-durable-state"],
69
72
  // -> enablePrincipal (admin-crud-service.ts): status flip back to active.
@@ -283,6 +286,17 @@ export function buildIdentityRegistrations(routeDeps) {
283
286
  throw new Error(`principal '${input.req("principalId")}' was not found`);
284
287
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
285
288
  },
289
+ identity_user_change_username: async (ctx) => {
290
+ const input = identityInput("identity_user_change_username", ctx.input);
291
+ const principalId = input.req("principalId");
292
+ const { user } = await updateUser({ deps: serviceDeps(), input: {
293
+ workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
294
+ } }, { username: input.req("username") });
295
+ const principal = await routeDeps.principalRepo.findById({ workspaceId: routeDeps.workspaceId, id: principalId });
296
+ if (!principal)
297
+ throw new Error(`principal '${principalId}' was not found`);
298
+ return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principalId) }) };
299
+ },
286
300
  identity_user_disable: async (ctx) => {
287
301
  const input = identityInput("identity_user_disable", ctx.input);
288
302
  const { principal } = await disablePrincipal({
@@ -106,6 +106,17 @@ export const identityAgentToolCatalog = [
106
106
  },
107
107
  },
108
108
  },
109
+ {
110
+ name: "identity_user_change_username",
111
+ description: "Changes an operator user's login username. Call after finding their principalId with identity_user_list. Returns { user } with the normalized username; email, password and existing sessions stay unchanged. Requires user.manage even for your own profile; only owners may rename owner accounts. Fails for blank names, missing users, users in Trash, or names reserved by any user including Trash. Choose a different name or restore/purge the conflicting trashed user before retrying.",
112
+ sideEffects: "mutates-durable-state",
113
+ authorization: { permission: "user.manage" },
114
+ inputSchema: {
115
+ type: "object", additionalProperties: false, required: ["principalId", "username"],
116
+ properties: { principalId: PRINCIPAL_ID_SCHEMA, username: { type: "string", minLength: 1,
117
+ description: "New login name. Trimmed, lowercased and Unicode NFC normalized; unique per workspace, including users in Trash." } },
118
+ },
119
+ },
109
120
  {
110
121
  name: "identity_user_disable",
111
122
  description: "Disables a user, revoking their access. Principals are never hard-deleted because the audit trail references them, so this is how a user is removed. Refuses to disable the seeded owner, and refuses any disable that would leave the workspace with no active owner. Already-disabled is a no-op.",
@@ -1,5 +1,5 @@
1
1
  import { type UserManagementMessages } from "./messages.js";
2
- import type { SessionTokenPort } from "../core/runtime-ports.js";
2
+ import type { SessionTokenPort, UsernameChangePort } from "../core/runtime-ports.js";
3
3
  import type { Clock, IdGenerator, ISODateTime, UUID } from "@jini-ai/core/primitives";
4
4
  import type { IdentityRepos, PasswordHasherPort } from "../core/ports.js";
5
5
  import { type PrincipalRecord, type SessionRecord } from "../core/types.js";
@@ -9,6 +9,8 @@ export interface AuthServiceDeps {
9
9
  clock: Clock;
10
10
  idGen: IdGenerator;
11
11
  tokens: SessionTokenPort;
12
+ /** Additive: legacy email updates need no rename lifecycle; renames require it. */
13
+ usernameChanges?: UsernameChangePort | undefined;
12
14
  }
13
15
  /**
14
16
  * Mint and persist a session for a principal the caller has ALREADY identified by some means
@@ -24,6 +24,8 @@ export interface SeedIdentityInput {
24
24
  }
25
25
  export interface SeedIdentityOptions {
26
26
  ownerEmail?: string | undefined;
27
+ /** Stable bootstrap binding persisted by the host. Never derive this from a mutable login/title. */
28
+ seededOwnerPrincipalId?: UUID | undefined;
27
29
  }
28
30
  export interface SeedIdentityResult {
29
31
  ownerPrincipalId: UUID;
@@ -33,8 +35,8 @@ export interface SeedIdentityResult {
33
35
  * Seed first-boot identity data.
34
36
  *
35
37
  * Idempotent in two distinct senses, and both are load-bearing:
36
- * - A seed that RAN TO COMPLETION is a no-op — the owner-username lookup below early-returns after
37
- * a single confirming read (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
38
+ * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
39
+ * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
38
40
  * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
39
41
  * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
40
42
  * `identityReady`.)
@@ -45,8 +47,9 @@ export interface SeedIdentityResult {
45
47
  * previous attempt already wrote and fills in only what is missing. See the block comment above
46
48
  * `ensureBuiltinRole` for why this function must survive being killed halfway.
47
49
  *
48
- * @complexity O(1) — fixed small number of inserts (4 roles/policies, ~2
49
- * dozen policy_permissions, 3 principals, 1 user, 1 role assignment).
50
+ * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
51
+ * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
52
+ * 3 principals, 1 user, 1 role assignment).
50
53
  * @overallScore 100
51
54
  */
52
55
  export declare function seedIdentity(required: {
@@ -1,5 +1,6 @@
1
1
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
2
2
  import { normalizeUsername } from "./username.js";
3
+ import { IdentityNotFoundError } from "../core/types.js";
3
4
  /**
4
5
  * @file First-boot identity seed (state.spec `SEED_FIRST_BOOT`).
5
6
  *
@@ -20,7 +21,7 @@ import { normalizeUsername } from "./username.js";
20
21
  * Seed data lives in one place so both composition roots stay identical
21
22
  * (mirrors `server/seed.ts`'s reasoning for workspace/post/presentation seed
22
23
  * data). Idempotent: re-seeding an already-seeded workspace is a no-op
23
- * (checked via the owner username).
24
+ * (checked via a host-supplied stable principal id, or the legacy owner username).
24
25
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
25
26
  */
26
27
  /** : the literal id the legacy Article VI actor stamp resolves to. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -303,8 +304,8 @@ async function ensureOwnerRoleBinding(required) {
303
304
  * Seed first-boot identity data.
304
305
  *
305
306
  * Idempotent in two distinct senses, and both are load-bearing:
306
- * - A seed that RAN TO COMPLETION is a no-op — the owner-username lookup below early-returns after
307
- * a single confirming read (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
307
+ * - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
308
+ * confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
308
309
  * reconcile a built-in policy whose permission list has since grown; that is the host's job — see
309
310
  * the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
310
311
  * `identityReady`.)
@@ -315,8 +316,9 @@ async function ensureOwnerRoleBinding(required) {
315
316
  * previous attempt already wrote and fills in only what is missing. See the block comment above
316
317
  * `ensureBuiltinRole` for why this function must survive being killed halfway.
317
318
  *
318
- * @complexity O(1) — fixed small number of inserts (4 roles/policies, ~2
319
- * dozen policy_permissions, 3 principals, 1 user, 1 role assignment).
319
+ * @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
320
+ * fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
321
+ * 3 principals, 1 user, 1 role assignment).
320
322
  * @overallScore 100
321
323
  */
322
324
  export async function seedIdentity(required, optional = {}) {
@@ -326,7 +328,14 @@ export async function seedIdentity(required, optional = {}) {
326
328
  const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
327
329
  const ownerPassword = input.ownerPassword;
328
330
  const nowIso = kernelNowIso({ clock: deps.clock });
329
- const existingOwnerUser = await deps.repos.users.findByUsername({
331
+ // An explicit binding is authoritative, even when somebody has reused its former login name.
332
+ // A missing bound row is an error, never permission to mint a replacement owner.
333
+ const seededOwnerUser = input.seededOwnerPrincipalId !== undefined
334
+ ? await deps.repos.users.findByPrincipalId({ workspaceId, principalId: input.seededOwnerPrincipalId }) : null;
335
+ if (input.seededOwnerPrincipalId !== undefined && !seededOwnerUser) {
336
+ throw new IdentityNotFoundError({ message: "the bound seeded owner user was not found; restore the owner identity before booting" });
337
+ }
338
+ const existingOwnerUser = seededOwnerUser ?? await deps.repos.users.findByUsername({
330
339
  workspaceId,
331
340
  username: ownerUsername,
332
341
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/user-management",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -87,16 +87,16 @@
87
87
  "registry": "https://registry.npmjs.org"
88
88
  },
89
89
  "dependencies": {
90
- "@jini-ai/core": "^0.4.2"
90
+ "@jini-ai/core": "^0.4.3"
91
91
  },
92
92
  "peerDependencies": {
93
93
  "argon2": "^0.44.0",
94
94
  "react": "^18.3.0 || ^19.0.0",
95
95
  "react-dom": "^18.3.0 || ^19.0.0",
96
- "@jini-ai/ui": "^0.4.0",
97
- "@jini-ai/admin": "^0.4.0",
98
- "@jini-ai/agentic": "^0.4.0",
99
- "@jini-ai/ui-kit": "^0.1.1"
96
+ "@jini-ai/ui": "^0.4.8",
97
+ "@jini-ai/admin": "^0.4.6",
98
+ "@jini-ai/agentic": "^0.4.4",
99
+ "@jini-ai/ui-kit": "^0.1.3"
100
100
  },
101
101
  "peerDependenciesMeta": {
102
102
  "argon2": {
@@ -134,12 +134,12 @@
134
134
  "@testing-library/user-event": "^14.5.0",
135
135
  "@testing-library/jest-dom": "^6.6.3",
136
136
  "jsdom": "^25.0.1",
137
- "@jini-ai/ui": "0.4.5",
138
- "@jini-ai/cms": "0.5.3",
139
- "@jini-ai/core": "^0.4.2",
140
- "@jini-ai/ui-kit": "^0.1.2",
141
- "@jini-ai/agentic": "0.4.3",
142
- "@jini-ai/admin": "0.4.3"
137
+ "@jini-ai/ui": "0.4.8",
138
+ "@jini-ai/core": "^0.4.3",
139
+ "@jini-ai/agentic": "0.4.4",
140
+ "@jini-ai/admin": "0.4.6",
141
+ "@jini-ai/ui-kit": "^0.1.3",
142
+ "@jini-ai/cms": "0.5.6"
143
143
  },
144
144
  "scripts": {
145
145
  "build": "tsc -p tsconfig.json",