@meetreeve/module-team 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ the same MAJOR.MINOR.PATCH.
17
17
  | `listMembers(orgId)` | `GET /api/org/members` |
18
18
  | `updateMemberRole(orgId, memberId, role)` | `PUT /api/org/members/{id}/role` |
19
19
  | `removeMember(orgId, memberId)` | `DELETE /api/org/members/{id}` |
20
- | `inviteMember(orgId, email, role)` | `POST /api/org/invite` |
20
+ | `inviteMember(orgId, email, role, spaces?)` | `POST /api/org/invite` (`spaces` sent whenever passed, `[]` included) |
21
21
  | `listInvitations(orgId)` | `GET /api/org/invitations` |
22
22
  | `revokeInvitation(orgId, invitationId)` | `DELETE /api/org/invitations/{id}` |
23
23
  | `resendInvitation(orgId, invitationId)` | `POST /api/org/invitations/{id}/resend` |
@@ -53,9 +53,25 @@ const client = createTeamClient({ fetch: (path, init) => productFetch(path, init
53
53
  />;
54
54
  ```
55
55
 
56
+ Spaces (0.2.0, DEV-15988; 0.3.0, DEV-16808): pass
57
+ `loadSpaceOptions={() => Promise<SpaceOption[]>}` and the invite dialog lists
58
+ every space, the default one included (nobody joins a space automatically).
59
+ With 2+ spaces at least one must be checked; with one there is no picker, the
60
+ dialog says "They'll get access to <Space>" and sends `spaces: []`. The invite
61
+ is held until the options load. `can_manage: false` spaces are shown
62
+ disabled. A server refusal (e.g. 422 `spaces_required`) shows inside the
63
+ dialog. `renderInvitationActions={(inv, { reload }) => ...}` is the
64
+ pending-invite twin of `renderMemberActions`; call `reload` after a change
65
+ the row shows (an invitation's `spaces` are listed on its row).
66
+
56
67
  `inviteRoles` scopes both the invite dialog and the per-member role picker.
57
- Owners get no role picker and no remove control. The backend still enforces
58
- every rule.
68
+ Owners get no role picker and no remove control. The role picker also offers
69
+ only the changes the backend accepts from the signed-in user, whose role is
70
+ read from the roster row matching `currentUserEmail`: an owner may move a
71
+ member to any offered role, an admin only to roles below admin and only on
72
+ rows ranked below admin, and anyone else gets plain role labels. Without a
73
+ matching `currentUserEmail` every role is a plain label. A refused change
74
+ shows the backend's `detail`. The backend still enforces every rule.
59
75
 
60
76
  The panel has no runtime dependencies beyond `react`/`react-dom`. It is
61
77
  styled with Tailwind utility classes over shadcn tokens (`bg-card`,
@@ -31,6 +31,38 @@ interface Invitation {
31
31
  /** ISO-8601. */
32
32
  expires_at: string;
33
33
  accepted_at: string | null;
34
+ /**
35
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
36
+ * The backend always sends it; optional so a backend that predates it
37
+ * still type-checks as "no pre-set spaces".
38
+ */
39
+ spaces?: InvitationSpace[];
40
+ }
41
+ /** A space role. */
42
+ type SpaceRole = "member" | "admin";
43
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
44
+ interface InvitationSpace {
45
+ id: string;
46
+ name: string;
47
+ role: SpaceRole;
48
+ }
49
+ /** One space an invite (or a pending invitation) should land the invitee in. */
50
+ interface InviteSpace {
51
+ space_id: string;
52
+ role: SpaceRole;
53
+ }
54
+ /**
55
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
56
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
57
+ * offered, the default one included: nobody joins a space automatically
58
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
59
+ */
60
+ interface SpaceOption {
61
+ id: string;
62
+ name: string;
63
+ kind?: string;
64
+ is_default: boolean;
65
+ can_manage: boolean;
34
66
  }
35
67
  /** `POST /api/org/invite`. */
36
68
  interface InviteMemberResponse {
@@ -50,7 +82,12 @@ interface TeamClient {
50
82
  listMembers(orgId: string): Promise<TeamMember[]>;
51
83
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
52
84
  removeMember(orgId: string, memberId: string): Promise<void>;
53
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
85
+ /**
86
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
87
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
88
+ * request.
89
+ */
90
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
54
91
  listInvitations(orgId: string): Promise<Invitation[]>;
55
92
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
56
93
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -68,7 +105,7 @@ interface TeamClient {
68
105
  * GET /api/org/members -> TeamMember[]
69
106
  * PUT /api/org/members/{id}/role {role} -> 2xx
70
107
  * DELETE /api/org/members/{id} -> 2xx
71
- * POST /api/org/invite {email, role} -> InviteMemberResponse
108
+ * POST /api/org/invite {email, role, spaces?} -> InviteMemberResponse
72
109
  * GET /api/org/invitations -> Invitation[]
73
110
  * DELETE /api/org/invitations/{id} -> 2xx
74
111
  * POST /api/org/invitations/{id}/resend -> 2xx (resets the 7-day expiry)
@@ -101,4 +138,4 @@ type TeamClientOptions = {
101
138
  };
102
139
  declare function createTeamClient({ fetch }: TeamClientOptions): TeamClient;
103
140
 
104
- export { type AssignableRole, type Invitation, type InviteMemberResponse, ORG_HEADER, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
141
+ export { type AssignableRole, type Invitation, type InvitationSpace, type InviteMemberResponse, type InviteSpace, ORG_HEADER, type SpaceOption, type SpaceRole, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
@@ -31,6 +31,38 @@ interface Invitation {
31
31
  /** ISO-8601. */
32
32
  expires_at: string;
33
33
  accepted_at: string | null;
34
+ /**
35
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
36
+ * The backend always sends it; optional so a backend that predates it
37
+ * still type-checks as "no pre-set spaces".
38
+ */
39
+ spaces?: InvitationSpace[];
40
+ }
41
+ /** A space role. */
42
+ type SpaceRole = "member" | "admin";
43
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
44
+ interface InvitationSpace {
45
+ id: string;
46
+ name: string;
47
+ role: SpaceRole;
48
+ }
49
+ /** One space an invite (or a pending invitation) should land the invitee in. */
50
+ interface InviteSpace {
51
+ space_id: string;
52
+ role: SpaceRole;
53
+ }
54
+ /**
55
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
56
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
57
+ * offered, the default one included: nobody joins a space automatically
58
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
59
+ */
60
+ interface SpaceOption {
61
+ id: string;
62
+ name: string;
63
+ kind?: string;
64
+ is_default: boolean;
65
+ can_manage: boolean;
34
66
  }
35
67
  /** `POST /api/org/invite`. */
36
68
  interface InviteMemberResponse {
@@ -50,7 +82,12 @@ interface TeamClient {
50
82
  listMembers(orgId: string): Promise<TeamMember[]>;
51
83
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
52
84
  removeMember(orgId: string, memberId: string): Promise<void>;
53
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
85
+ /**
86
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
87
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
88
+ * request.
89
+ */
90
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
54
91
  listInvitations(orgId: string): Promise<Invitation[]>;
55
92
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
56
93
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -68,7 +105,7 @@ interface TeamClient {
68
105
  * GET /api/org/members -> TeamMember[]
69
106
  * PUT /api/org/members/{id}/role {role} -> 2xx
70
107
  * DELETE /api/org/members/{id} -> 2xx
71
- * POST /api/org/invite {email, role} -> InviteMemberResponse
108
+ * POST /api/org/invite {email, role, spaces?} -> InviteMemberResponse
72
109
  * GET /api/org/invitations -> Invitation[]
73
110
  * DELETE /api/org/invitations/{id} -> 2xx
74
111
  * POST /api/org/invitations/{id}/resend -> 2xx (resets the 7-day expiry)
@@ -101,4 +138,4 @@ type TeamClientOptions = {
101
138
  };
102
139
  declare function createTeamClient({ fetch }: TeamClientOptions): TeamClient;
103
140
 
104
- export { type AssignableRole, type Invitation, type InviteMemberResponse, ORG_HEADER, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
141
+ export { type AssignableRole, type Invitation, type InvitationSpace, type InviteMemberResponse, type InviteSpace, ORG_HEADER, type SpaceOption, type SpaceRole, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
@@ -71,12 +71,14 @@ function createTeamClient({ fetch }) {
71
71
  removeMember: (orgId, memberId) => send(orgId, "DELETE", `/api/org/members/${segment(memberId, "memberId")}`).then(
72
72
  () => void 0
73
73
  ),
74
- inviteMember: (orgId, email, role) => {
74
+ inviteMember: (orgId, email, role, spaces) => {
75
75
  if (typeof email !== "string" || email.trim() === "") {
76
76
  throw new Error("email must be a non-empty string");
77
77
  }
78
78
  segment(role, "role");
79
- return send(orgId, "POST", "/api/org/invite", { email: email.trim(), role }).then(
79
+ const body = { email: email.trim(), role };
80
+ if (spaces) body.spaces = spaces;
81
+ return send(orgId, "POST", "/api/org/invite", body).then(
80
82
  (d) => d
81
83
  );
82
84
  },
@@ -46,12 +46,14 @@ function createTeamClient({ fetch }) {
46
46
  removeMember: (orgId, memberId) => send(orgId, "DELETE", `/api/org/members/${segment(memberId, "memberId")}`).then(
47
47
  () => void 0
48
48
  ),
49
- inviteMember: (orgId, email, role) => {
49
+ inviteMember: (orgId, email, role, spaces) => {
50
50
  if (typeof email !== "string" || email.trim() === "") {
51
51
  throw new Error("email must be a non-empty string");
52
52
  }
53
53
  segment(role, "role");
54
- return send(orgId, "POST", "/api/org/invite", { email: email.trim(), role }).then(
54
+ const body = { email: email.trim(), role };
55
+ if (spaces) body.spaces = spaces;
56
+ return send(orgId, "POST", "/api/org/invite", body).then(
55
57
  (d) => d
56
58
  );
57
59
  },
@@ -34,6 +34,38 @@ interface Invitation {
34
34
  /** ISO-8601. */
35
35
  expires_at: string;
36
36
  accepted_at: string | null;
37
+ /**
38
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
39
+ * The backend always sends it; optional so a backend that predates it
40
+ * still type-checks as "no pre-set spaces".
41
+ */
42
+ spaces?: InvitationSpace[];
43
+ }
44
+ /** A space role. */
45
+ type SpaceRole = "member" | "admin";
46
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
47
+ interface InvitationSpace {
48
+ id: string;
49
+ name: string;
50
+ role: SpaceRole;
51
+ }
52
+ /** One space an invite (or a pending invitation) should land the invitee in. */
53
+ interface InviteSpace {
54
+ space_id: string;
55
+ role: SpaceRole;
56
+ }
57
+ /**
58
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
59
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
60
+ * offered, the default one included: nobody joins a space automatically
61
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
62
+ */
63
+ interface SpaceOption {
64
+ id: string;
65
+ name: string;
66
+ kind?: string;
67
+ is_default: boolean;
68
+ can_manage: boolean;
37
69
  }
38
70
  /** `POST /api/org/invite`. */
39
71
  interface InviteMemberResponse {
@@ -53,7 +85,12 @@ interface TeamClient {
53
85
  listMembers(orgId: string): Promise<TeamMember[]>;
54
86
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
55
87
  removeMember(orgId: string, memberId: string): Promise<void>;
56
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
88
+ /**
89
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
90
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
91
+ * request.
92
+ */
93
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
57
94
  listInvitations(orgId: string): Promise<Invitation[]>;
58
95
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
59
96
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -63,6 +100,11 @@ type TeamNotice = {
63
100
  kind: "success" | "error";
64
101
  message: string;
65
102
  };
103
+ /** What `renderInvitationActions` gets besides the invitation. */
104
+ interface InvitationActionHelpers {
105
+ /** Refetch members and invitations, as Resend does after it lands. */
106
+ reload: () => Promise<void>;
107
+ }
66
108
  interface TeamMembersPanelProps {
67
109
  /**
68
110
  * Data access. `createTeamClient({ fetch })` from `@meetreeve/module-team/client`.
@@ -80,7 +122,11 @@ interface TeamMembersPanelProps {
80
122
  * `["admin", "member"]`.
81
123
  */
82
124
  inviteRoles?: AssignableRole[];
83
- /** The signed-in user's email: marks their row "you". */
125
+ /**
126
+ * The signed-in user's email: marks their row "you", and that row's role
127
+ * decides which role changes the picker offers (DEV-18190). Without it, or
128
+ * when no member matches, every role shows as a plain label.
129
+ */
84
130
  currentUserEmail?: string | null;
85
131
  /**
86
132
  * Host-owned per-member controls (Studio's Budget / Capabilities dialogs).
@@ -88,6 +134,19 @@ interface TeamMembersPanelProps {
88
134
  * decides per member what to show.
89
135
  */
90
136
  renderMemberActions?: (member: TeamMember) => ReactNode;
137
+ /**
138
+ * Host-owned per-invitation controls, rendered next to Resend / Revoke on
139
+ * every pending invitation. Call `reload` after a change the row shows.
140
+ */
141
+ renderInvitationActions?: (invitation: Invitation, helpers: InvitationActionHelpers) => ReactNode;
142
+ /**
143
+ * The org's spaces for the invite dialog's Spaces picker. When omitted the
144
+ * dialog has no picker and invites go out exactly as before. Called each
145
+ * time the dialog opens; until it answers, the invite is held, since an org
146
+ * with several spaces refuses an invite that names none (422
147
+ * `spaces_required`).
148
+ */
149
+ loadSpaceOptions?: () => Promise<SpaceOption[]>;
91
150
  /** Called with every success/error notice the panel shows inline. */
92
151
  onNotice?: (notice: TeamNotice) => void;
93
152
  }
@@ -97,4 +156,4 @@ interface TeamMembersPanelProps {
97
156
  */
98
157
  declare function TeamMembersPanel(props: TeamMembersPanelProps): react.JSX.Element;
99
158
 
100
- export { type AssignableRole, type Invitation, type InviteMemberResponse, type TeamClient, type TeamMember, TeamMembersPanel, type TeamMembersPanelProps, type TeamNotice, type TeamRole };
159
+ export { type AssignableRole, type Invitation, type InvitationActionHelpers, type InvitationSpace, type InviteMemberResponse, type InviteSpace, type SpaceOption, type SpaceRole, type TeamClient, type TeamMember, TeamMembersPanel, type TeamMembersPanelProps, type TeamNotice, type TeamRole };
@@ -34,6 +34,38 @@ interface Invitation {
34
34
  /** ISO-8601. */
35
35
  expires_at: string;
36
36
  accepted_at: string | null;
37
+ /**
38
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
39
+ * The backend always sends it; optional so a backend that predates it
40
+ * still type-checks as "no pre-set spaces".
41
+ */
42
+ spaces?: InvitationSpace[];
43
+ }
44
+ /** A space role. */
45
+ type SpaceRole = "member" | "admin";
46
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
47
+ interface InvitationSpace {
48
+ id: string;
49
+ name: string;
50
+ role: SpaceRole;
51
+ }
52
+ /** One space an invite (or a pending invitation) should land the invitee in. */
53
+ interface InviteSpace {
54
+ space_id: string;
55
+ role: SpaceRole;
56
+ }
57
+ /**
58
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
59
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
60
+ * offered, the default one included: nobody joins a space automatically
61
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
62
+ */
63
+ interface SpaceOption {
64
+ id: string;
65
+ name: string;
66
+ kind?: string;
67
+ is_default: boolean;
68
+ can_manage: boolean;
37
69
  }
38
70
  /** `POST /api/org/invite`. */
39
71
  interface InviteMemberResponse {
@@ -53,7 +85,12 @@ interface TeamClient {
53
85
  listMembers(orgId: string): Promise<TeamMember[]>;
54
86
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
55
87
  removeMember(orgId: string, memberId: string): Promise<void>;
56
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
88
+ /**
89
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
90
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
91
+ * request.
92
+ */
93
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
57
94
  listInvitations(orgId: string): Promise<Invitation[]>;
58
95
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
59
96
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -63,6 +100,11 @@ type TeamNotice = {
63
100
  kind: "success" | "error";
64
101
  message: string;
65
102
  };
103
+ /** What `renderInvitationActions` gets besides the invitation. */
104
+ interface InvitationActionHelpers {
105
+ /** Refetch members and invitations, as Resend does after it lands. */
106
+ reload: () => Promise<void>;
107
+ }
66
108
  interface TeamMembersPanelProps {
67
109
  /**
68
110
  * Data access. `createTeamClient({ fetch })` from `@meetreeve/module-team/client`.
@@ -80,7 +122,11 @@ interface TeamMembersPanelProps {
80
122
  * `["admin", "member"]`.
81
123
  */
82
124
  inviteRoles?: AssignableRole[];
83
- /** The signed-in user's email: marks their row "you". */
125
+ /**
126
+ * The signed-in user's email: marks their row "you", and that row's role
127
+ * decides which role changes the picker offers (DEV-18190). Without it, or
128
+ * when no member matches, every role shows as a plain label.
129
+ */
84
130
  currentUserEmail?: string | null;
85
131
  /**
86
132
  * Host-owned per-member controls (Studio's Budget / Capabilities dialogs).
@@ -88,6 +134,19 @@ interface TeamMembersPanelProps {
88
134
  * decides per member what to show.
89
135
  */
90
136
  renderMemberActions?: (member: TeamMember) => ReactNode;
137
+ /**
138
+ * Host-owned per-invitation controls, rendered next to Resend / Revoke on
139
+ * every pending invitation. Call `reload` after a change the row shows.
140
+ */
141
+ renderInvitationActions?: (invitation: Invitation, helpers: InvitationActionHelpers) => ReactNode;
142
+ /**
143
+ * The org's spaces for the invite dialog's Spaces picker. When omitted the
144
+ * dialog has no picker and invites go out exactly as before. Called each
145
+ * time the dialog opens; until it answers, the invite is held, since an org
146
+ * with several spaces refuses an invite that names none (422
147
+ * `spaces_required`).
148
+ */
149
+ loadSpaceOptions?: () => Promise<SpaceOption[]>;
91
150
  /** Called with every success/error notice the panel shows inline. */
92
151
  onNotice?: (notice: TeamNotice) => void;
93
152
  }
@@ -97,4 +156,4 @@ interface TeamMembersPanelProps {
97
156
  */
98
157
  declare function TeamMembersPanel(props: TeamMembersPanelProps): react.JSX.Element;
99
158
 
100
- export { type AssignableRole, type Invitation, type InviteMemberResponse, type TeamClient, type TeamMember, TeamMembersPanel, type TeamMembersPanelProps, type TeamNotice, type TeamRole };
159
+ export { type AssignableRole, type Invitation, type InvitationActionHelpers, type InvitationSpace, type InviteMemberResponse, type InviteSpace, type SpaceOption, type SpaceRole, type TeamClient, type TeamMember, TeamMembersPanel, type TeamMembersPanelProps, type TeamNotice, type TeamRole };
@@ -88,6 +88,30 @@ function Modal({
88
88
  );
89
89
  }
90
90
 
91
+ // src/config/role-rules.ts
92
+ var MEMBER_ROLE_RANK = {
93
+ viewer: 5,
94
+ member: 10,
95
+ billing_manager: 15,
96
+ admin: 20,
97
+ owner: 30
98
+ };
99
+ function memberRoleRank(role) {
100
+ return Object.prototype.hasOwnProperty.call(MEMBER_ROLE_RANK, role) ? MEMBER_ROLE_RANK[role] : -1;
101
+ }
102
+ function canChangeMemberRole(callerRole, targetRole, requestedRole) {
103
+ if (callerRole !== "owner" && callerRole !== "admin") return false;
104
+ if (memberRoleRank(requestedRole) < 0) return false;
105
+ if (requestedRole === "owner" && callerRole !== "owner") return false;
106
+ const caller = memberRoleRank(callerRole);
107
+ const target = memberRoleRank(targetRole);
108
+ const requested = memberRoleRank(requestedRole);
109
+ return callerRole === "owner" ? caller >= target && caller >= requested : caller > target && caller > requested;
110
+ }
111
+ function grantableRoles(callerRole, targetRole, offered) {
112
+ return offered.filter((role) => role !== targetRole && canChangeMemberRole(callerRole, targetRole, role));
113
+ }
114
+
91
115
  // src/config/team-members-panel.tsx
92
116
  var import_jsx_runtime2 = require("react/jsx-runtime");
93
117
  var DEFAULT_INVITE_ROLES = ["admin", "member", "viewer"];
@@ -138,11 +162,18 @@ function formatAgo(iso) {
138
162
  }
139
163
  function errorInfo(err, fallback) {
140
164
  const e = typeof err === "object" && err !== null ? err : {};
141
- const detail = typeof e.body?.detail === "string" ? e.body.detail : "";
165
+ const raw = e.body?.detail;
166
+ const nested = typeof raw === "object" && raw !== null ? raw.message : void 0;
167
+ const detail = typeof raw === "string" ? raw : typeof nested === "string" ? nested : "";
142
168
  const message = typeof e.message === "string" ? e.message : "";
143
169
  const status = typeof e.status === "number" ? e.status : Number(/^(?:API error )?(\d{3})\b/.exec(message)?.[1]) || null;
144
170
  return { status, message: detail || message || fallback };
145
171
  }
172
+ function serverDetail(err) {
173
+ const body = typeof err === "object" && err !== null ? err.body : null;
174
+ const detail = typeof body === "object" && body !== null ? body.detail : null;
175
+ return typeof detail === "string" && detail.trim() !== "" ? detail : null;
176
+ }
146
177
  function plural(n, one, many) {
147
178
  return `${n} ${n === 1 ? one : many}`;
148
179
  }
@@ -166,6 +197,8 @@ function TeamMembersPanelBody({
166
197
  inviteRoles = DEFAULT_INVITE_ROLES,
167
198
  currentUserEmail,
168
199
  renderMemberActions,
200
+ renderInvitationActions,
201
+ loadSpaceOptions,
169
202
  onNotice
170
203
  }) {
171
204
  const ids = (0, import_react2.useId)();
@@ -179,6 +212,9 @@ function TeamMembersPanelBody({
179
212
  const [inviteRole, setInviteRole] = (0, import_react2.useState)(defaultInviteRole);
180
213
  const [inviteSubmitting, setInviteSubmitting] = (0, import_react2.useState)(false);
181
214
  const [memberToRemove, setMemberToRemove] = (0, import_react2.useState)(null);
215
+ const [spaceOptions, setSpaceOptions] = (0, import_react2.useState)({ status: "loading" });
216
+ const [inviteSpaceIds, setInviteSpaceIds] = (0, import_react2.useState)([]);
217
+ const [inviteError, setInviteError] = (0, import_react2.useState)(null);
182
218
  const mounted = (0, import_react2.useRef)(true);
183
219
  const generation = (0, import_react2.useRef)(0);
184
220
  const notify = (0, import_react2.useCallback)(
@@ -214,6 +250,22 @@ function TeamMembersPanelBody({
214
250
  mounted.current = false;
215
251
  };
216
252
  }, [load]);
253
+ (0, import_react2.useEffect)(() => {
254
+ if (!inviteOpen || !loadSpaceOptions) return;
255
+ let live = true;
256
+ setSpaceOptions({ status: "loading" });
257
+ loadSpaceOptions().then(
258
+ (options) => {
259
+ if (live && mounted.current) setSpaceOptions({ status: "ready", options });
260
+ },
261
+ () => {
262
+ if (live && mounted.current) setSpaceOptions({ status: "error" });
263
+ }
264
+ );
265
+ return () => {
266
+ live = false;
267
+ };
268
+ }, [inviteOpen, loadSpaceOptions]);
217
269
  const setBusyFor = (key, value) => {
218
270
  if (mounted.current) setBusy((b) => ({ ...b, [key]: value }));
219
271
  };
@@ -221,24 +273,38 @@ function TeamMembersPanelBody({
221
273
  if (!mounted.current) return;
222
274
  setState((s) => s.status === "ready" ? fn(s) : s);
223
275
  };
276
+ const closeInvite = () => {
277
+ setInviteSpaceIds([]);
278
+ setInviteError(null);
279
+ setInviteOpen(false);
280
+ };
281
+ const offered = spaceOptions.status === "ready" ? spaceOptions.options : null;
282
+ const pickRequired = offered !== null && offered.length > 1;
283
+ const pickedSpaces = (offered ?? []).filter((o) => o.can_manage && inviteSpaceIds.includes(o.id)).map((o) => ({ space_id: o.id, role: "member" }));
284
+ const inviteSpacesChanged = offered !== null && pickRequired && inviteSpaceIds.some((id) => !offered.some((o) => o.id === id && o.can_manage));
285
+ const inviteSpacesBlocked = loadSpaceOptions !== void 0 && (offered === null || inviteSpacesChanged || pickRequired && pickedSpaces.length === 0);
224
286
  const handleInvite = async () => {
225
287
  const email = inviteEmail.trim();
226
- if (!email || inviteSubmitting) return;
288
+ if (!email || inviteSubmitting || inviteSpacesBlocked) return;
227
289
  setInviteSubmitting(true);
290
+ setInviteError(null);
228
291
  try {
229
- await client.inviteMember(orgId, email, inviteRole);
292
+ if (loadSpaceOptions) {
293
+ await client.inviteMember(orgId, email, inviteRole, pickRequired ? pickedSpaces : []);
294
+ } else {
295
+ await client.inviteMember(orgId, email, inviteRole);
296
+ }
230
297
  if (!mounted.current) return;
231
298
  notify({ kind: "success", message: `Invitation sent to ${email}` });
232
299
  setInviteEmail("");
233
300
  setInviteRole(defaultInviteRole);
234
- setInviteOpen(false);
301
+ closeInvite();
235
302
  await load();
236
303
  } catch (err) {
237
304
  const { status, message } = errorInfo(err, "Failed to send invitation");
238
- notify({
239
- kind: "error",
240
- message: status === 409 ? "A pending invitation already exists for this email" : message
241
- });
305
+ const shown = status === 409 ? "A pending invitation already exists for this email" : message;
306
+ if (mounted.current) setInviteError(shown);
307
+ notify({ kind: "error", message: shown });
242
308
  } finally {
243
309
  if (mounted.current) setInviteSubmitting(false);
244
310
  }
@@ -280,8 +346,8 @@ function TeamMembersPanelBody({
280
346
  ...s,
281
347
  members: s.members.map((m) => m.id === member.id ? { ...m, role } : m)
282
348
  }));
283
- } catch {
284
- notify({ kind: "error", message: "Failed to update role" });
349
+ } catch (err) {
350
+ notify({ kind: "error", message: serverDetail(err) ?? "Failed to update role" });
285
351
  } finally {
286
352
  setBusyFor(key, false);
287
353
  }
@@ -308,6 +374,7 @@ function TeamMembersPanelBody({
308
374
  };
309
375
  const ready = state.status === "ready" ? state : null;
310
376
  const me = currentUserEmail?.toLowerCase() ?? null;
377
+ const callerRole = me !== null && ready?.members.find((m) => m.email?.toLowerCase() === me)?.role || null;
311
378
  const inviteTitleId = `${ids}-invite-title`;
312
379
  const inviteDescId = `${ids}-invite-desc`;
313
380
  const removeTitleId = `${ids}-remove-title`;
@@ -378,7 +445,8 @@ function TeamMembersPanelBody({
378
445
  const isMe = me !== null && member.email?.toLowerCase() === me;
379
446
  const isOwner = member.role === "owner";
380
447
  const rowBusy = Boolean(busy[`member:${member.id}`]);
381
- const pickerRoles = roleOptions.includes(member.role) ? roleOptions : [member.role, ...roleOptions];
448
+ const grantable = grantableRoles(callerRole, member.role, roleOptions);
449
+ const pickerRoles = roleOptions.includes(member.role) ? roleOptions.filter((r) => r === member.role || grantable.includes(r)) : [member.role, ...grantable];
382
450
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
383
451
  "li",
384
452
  {
@@ -408,7 +476,7 @@ function TeamMembersPanelBody({
408
476
  ] }),
409
477
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: [
410
478
  isOwner ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: roleBadgeClass(member.role), children: roleLabel(member.role) }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
411
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
479
+ grantable.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: roleBadgeClass(member.role), children: roleLabel(member.role) }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
412
480
  "select",
413
481
  {
414
482
  "aria-label": `Role for ${name}`,
@@ -469,7 +537,11 @@ function TeamMembersPanelBody({
469
537
  inv.invited_by_email ?? "a team member",
470
538
  " \xB7 expires ",
471
539
  formatDate(inv.expires_at)
472
- ] })
540
+ ] }),
541
+ inv.spaces && inv.spaces.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { className: "truncate text-xs text-muted-foreground", children: [
542
+ "Spaces: ",
543
+ inv.spaces.map((sp) => sp.name).join(", ")
544
+ ] }) : null
473
545
  ] }),
474
546
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex shrink-0 items-center gap-2", children: [
475
547
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: roleBadgeClass(inv.role), children: roleLabel(inv.role) }),
@@ -494,7 +566,8 @@ function TeamMembersPanelBody({
494
566
  onClick: () => void handleRevoke(inv),
495
567
  children: "Revoke"
496
568
  }
497
- )
569
+ ),
570
+ renderInvitationActions?.(inv, { reload: load })
498
571
  ] })
499
572
  ]
500
573
  },
@@ -508,7 +581,7 @@ function TeamMembersPanelBody({
508
581
  Modal,
509
582
  {
510
583
  open: inviteOpen,
511
- onClose: () => setInviteOpen(false),
584
+ onClose: closeInvite,
512
585
  titleId: inviteTitleId,
513
586
  descriptionId: inviteDescId,
514
587
  dismissible: !inviteSubmitting,
@@ -555,6 +628,19 @@ function TeamMembersPanelBody({
555
628
  ),
556
629
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-xs text-muted-foreground", children: INVITE_ROLE_OPTIONS[inviteRole]?.description })
557
630
  ] }),
631
+ inviteError ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "alert", className: "text-sm text-destructive", children: inviteError }) : null,
632
+ loadSpaceOptions ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
633
+ InviteSpacesPicker,
634
+ {
635
+ state: spaceOptions,
636
+ selected: inviteSpaceIds,
637
+ selectionChanged: inviteSpacesChanged,
638
+ disabled: inviteSubmitting,
639
+ onToggle: (id, checked) => setInviteSpaceIds(
640
+ (prev) => checked ? [...prev.filter((x) => x !== id), id] : prev.filter((x) => x !== id)
641
+ )
642
+ }
643
+ ) : null,
558
644
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex justify-end gap-2", children: [
559
645
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
560
646
  "button",
@@ -562,7 +648,7 @@ function TeamMembersPanelBody({
562
648
  type: "button",
563
649
  className: BUTTON_OUTLINE,
564
650
  disabled: inviteSubmitting,
565
- onClick: () => setInviteOpen(false),
651
+ onClick: closeInvite,
566
652
  children: "Cancel"
567
653
  }
568
654
  ),
@@ -571,7 +657,7 @@ function TeamMembersPanelBody({
571
657
  {
572
658
  type: "submit",
573
659
  className: BUTTON_PRIMARY,
574
- disabled: !inviteEmail.trim() || inviteSubmitting,
660
+ disabled: !inviteEmail.trim() || inviteSubmitting || inviteSpacesBlocked,
575
661
  children: inviteSubmitting ? "Sending\u2026" : "Send Invitation"
576
662
  }
577
663
  )
@@ -627,6 +713,57 @@ function TeamMembersPanelBody({
627
713
  )
628
714
  ] });
629
715
  }
716
+ function InviteSpacesPicker({
717
+ state,
718
+ selected,
719
+ selectionChanged,
720
+ disabled,
721
+ onToggle
722
+ }) {
723
+ const ids = (0, import_react2.useId)();
724
+ if (state.status === "loading") {
725
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm text-muted-foreground", children: "Loading spaces\u2026" });
726
+ }
727
+ if (state.status === "error") {
728
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm text-destructive", children: selected.length > 0 ? "Couldn\u2019t load spaces, so the checked ones can\u2019t be sent. Cancel and reopen to retry." : "Couldn\u2019t load spaces, so the invite can\u2019t be sent yet. Cancel and reopen to retry." });
729
+ }
730
+ const { options } = state;
731
+ if (options.length === 1) {
732
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { className: "text-sm text-muted-foreground", children: [
733
+ "They\u2019ll get access to ",
734
+ options[0].name,
735
+ "."
736
+ ] });
737
+ }
738
+ if (options.length === 0) return null;
739
+ const picked = options.some((o) => o.can_manage && selected.includes(o.id));
740
+ const manageable = options.some((o) => o.can_manage);
741
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { className: "space-y-2", disabled, "aria-labelledby": `${ids}-legend`, children: [
742
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { id: `${ids}-legend`, className: "text-sm font-medium", children: "Spaces" }),
743
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-xs text-muted-foreground", children: "The spaces they\u2019ll join." }),
744
+ selectionChanged ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm text-destructive", children: "A checked space changed or is no longer manageable. Cancel and reopen to review the assignment before sending." }) : !manageable ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm text-destructive", children: "You can\u2019t add people to any of these spaces. Ask an org owner or admin to send this invite." }) : !picked ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "text-sm text-muted-foreground", children: "Pick at least one space." }) : null,
745
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { className: "max-h-48 divide-y divide-border overflow-y-auto rounded-md border border-border", children: options.map((o) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
746
+ "label",
747
+ {
748
+ className: `flex items-center gap-3 px-3 py-2 text-sm ${o.can_manage ? "cursor-pointer" : "cursor-not-allowed text-muted-foreground"}`,
749
+ children: [
750
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
751
+ "input",
752
+ {
753
+ type: "checkbox",
754
+ className: "accent-foreground",
755
+ checked: o.can_manage && selected.includes(o.id),
756
+ disabled: !o.can_manage,
757
+ onChange: (e) => onToggle(o.id, e.target.checked)
758
+ }
759
+ ),
760
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: o.name }),
761
+ o.can_manage ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "ml-auto text-xs", children: "You can't manage this space" })
762
+ ]
763
+ }
764
+ ) }, o.id)) })
765
+ ] });
766
+ }
630
767
  // Annotate the CommonJS export names for ESM import in node:
631
768
  0 && (module.exports = {
632
769
  TeamMembersPanel
@@ -63,6 +63,30 @@ function Modal({
63
63
  );
64
64
  }
65
65
 
66
+ // src/config/role-rules.ts
67
+ var MEMBER_ROLE_RANK = {
68
+ viewer: 5,
69
+ member: 10,
70
+ billing_manager: 15,
71
+ admin: 20,
72
+ owner: 30
73
+ };
74
+ function memberRoleRank(role) {
75
+ return Object.prototype.hasOwnProperty.call(MEMBER_ROLE_RANK, role) ? MEMBER_ROLE_RANK[role] : -1;
76
+ }
77
+ function canChangeMemberRole(callerRole, targetRole, requestedRole) {
78
+ if (callerRole !== "owner" && callerRole !== "admin") return false;
79
+ if (memberRoleRank(requestedRole) < 0) return false;
80
+ if (requestedRole === "owner" && callerRole !== "owner") return false;
81
+ const caller = memberRoleRank(callerRole);
82
+ const target = memberRoleRank(targetRole);
83
+ const requested = memberRoleRank(requestedRole);
84
+ return callerRole === "owner" ? caller >= target && caller >= requested : caller > target && caller > requested;
85
+ }
86
+ function grantableRoles(callerRole, targetRole, offered) {
87
+ return offered.filter((role) => role !== targetRole && canChangeMemberRole(callerRole, targetRole, role));
88
+ }
89
+
66
90
  // src/config/team-members-panel.tsx
67
91
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
68
92
  var DEFAULT_INVITE_ROLES = ["admin", "member", "viewer"];
@@ -113,11 +137,18 @@ function formatAgo(iso) {
113
137
  }
114
138
  function errorInfo(err, fallback) {
115
139
  const e = typeof err === "object" && err !== null ? err : {};
116
- const detail = typeof e.body?.detail === "string" ? e.body.detail : "";
140
+ const raw = e.body?.detail;
141
+ const nested = typeof raw === "object" && raw !== null ? raw.message : void 0;
142
+ const detail = typeof raw === "string" ? raw : typeof nested === "string" ? nested : "";
117
143
  const message = typeof e.message === "string" ? e.message : "";
118
144
  const status = typeof e.status === "number" ? e.status : Number(/^(?:API error )?(\d{3})\b/.exec(message)?.[1]) || null;
119
145
  return { status, message: detail || message || fallback };
120
146
  }
147
+ function serverDetail(err) {
148
+ const body = typeof err === "object" && err !== null ? err.body : null;
149
+ const detail = typeof body === "object" && body !== null ? body.detail : null;
150
+ return typeof detail === "string" && detail.trim() !== "" ? detail : null;
151
+ }
121
152
  function plural(n, one, many) {
122
153
  return `${n} ${n === 1 ? one : many}`;
123
154
  }
@@ -141,6 +172,8 @@ function TeamMembersPanelBody({
141
172
  inviteRoles = DEFAULT_INVITE_ROLES,
142
173
  currentUserEmail,
143
174
  renderMemberActions,
175
+ renderInvitationActions,
176
+ loadSpaceOptions,
144
177
  onNotice
145
178
  }) {
146
179
  const ids = useId();
@@ -154,6 +187,9 @@ function TeamMembersPanelBody({
154
187
  const [inviteRole, setInviteRole] = useState(defaultInviteRole);
155
188
  const [inviteSubmitting, setInviteSubmitting] = useState(false);
156
189
  const [memberToRemove, setMemberToRemove] = useState(null);
190
+ const [spaceOptions, setSpaceOptions] = useState({ status: "loading" });
191
+ const [inviteSpaceIds, setInviteSpaceIds] = useState([]);
192
+ const [inviteError, setInviteError] = useState(null);
157
193
  const mounted = useRef2(true);
158
194
  const generation = useRef2(0);
159
195
  const notify = useCallback(
@@ -189,6 +225,22 @@ function TeamMembersPanelBody({
189
225
  mounted.current = false;
190
226
  };
191
227
  }, [load]);
228
+ useEffect2(() => {
229
+ if (!inviteOpen || !loadSpaceOptions) return;
230
+ let live = true;
231
+ setSpaceOptions({ status: "loading" });
232
+ loadSpaceOptions().then(
233
+ (options) => {
234
+ if (live && mounted.current) setSpaceOptions({ status: "ready", options });
235
+ },
236
+ () => {
237
+ if (live && mounted.current) setSpaceOptions({ status: "error" });
238
+ }
239
+ );
240
+ return () => {
241
+ live = false;
242
+ };
243
+ }, [inviteOpen, loadSpaceOptions]);
192
244
  const setBusyFor = (key, value) => {
193
245
  if (mounted.current) setBusy((b) => ({ ...b, [key]: value }));
194
246
  };
@@ -196,24 +248,38 @@ function TeamMembersPanelBody({
196
248
  if (!mounted.current) return;
197
249
  setState((s) => s.status === "ready" ? fn(s) : s);
198
250
  };
251
+ const closeInvite = () => {
252
+ setInviteSpaceIds([]);
253
+ setInviteError(null);
254
+ setInviteOpen(false);
255
+ };
256
+ const offered = spaceOptions.status === "ready" ? spaceOptions.options : null;
257
+ const pickRequired = offered !== null && offered.length > 1;
258
+ const pickedSpaces = (offered ?? []).filter((o) => o.can_manage && inviteSpaceIds.includes(o.id)).map((o) => ({ space_id: o.id, role: "member" }));
259
+ const inviteSpacesChanged = offered !== null && pickRequired && inviteSpaceIds.some((id) => !offered.some((o) => o.id === id && o.can_manage));
260
+ const inviteSpacesBlocked = loadSpaceOptions !== void 0 && (offered === null || inviteSpacesChanged || pickRequired && pickedSpaces.length === 0);
199
261
  const handleInvite = async () => {
200
262
  const email = inviteEmail.trim();
201
- if (!email || inviteSubmitting) return;
263
+ if (!email || inviteSubmitting || inviteSpacesBlocked) return;
202
264
  setInviteSubmitting(true);
265
+ setInviteError(null);
203
266
  try {
204
- await client.inviteMember(orgId, email, inviteRole);
267
+ if (loadSpaceOptions) {
268
+ await client.inviteMember(orgId, email, inviteRole, pickRequired ? pickedSpaces : []);
269
+ } else {
270
+ await client.inviteMember(orgId, email, inviteRole);
271
+ }
205
272
  if (!mounted.current) return;
206
273
  notify({ kind: "success", message: `Invitation sent to ${email}` });
207
274
  setInviteEmail("");
208
275
  setInviteRole(defaultInviteRole);
209
- setInviteOpen(false);
276
+ closeInvite();
210
277
  await load();
211
278
  } catch (err) {
212
279
  const { status, message } = errorInfo(err, "Failed to send invitation");
213
- notify({
214
- kind: "error",
215
- message: status === 409 ? "A pending invitation already exists for this email" : message
216
- });
280
+ const shown = status === 409 ? "A pending invitation already exists for this email" : message;
281
+ if (mounted.current) setInviteError(shown);
282
+ notify({ kind: "error", message: shown });
217
283
  } finally {
218
284
  if (mounted.current) setInviteSubmitting(false);
219
285
  }
@@ -255,8 +321,8 @@ function TeamMembersPanelBody({
255
321
  ...s,
256
322
  members: s.members.map((m) => m.id === member.id ? { ...m, role } : m)
257
323
  }));
258
- } catch {
259
- notify({ kind: "error", message: "Failed to update role" });
324
+ } catch (err) {
325
+ notify({ kind: "error", message: serverDetail(err) ?? "Failed to update role" });
260
326
  } finally {
261
327
  setBusyFor(key, false);
262
328
  }
@@ -283,6 +349,7 @@ function TeamMembersPanelBody({
283
349
  };
284
350
  const ready = state.status === "ready" ? state : null;
285
351
  const me = currentUserEmail?.toLowerCase() ?? null;
352
+ const callerRole = me !== null && ready?.members.find((m) => m.email?.toLowerCase() === me)?.role || null;
286
353
  const inviteTitleId = `${ids}-invite-title`;
287
354
  const inviteDescId = `${ids}-invite-desc`;
288
355
  const removeTitleId = `${ids}-remove-title`;
@@ -353,7 +420,8 @@ function TeamMembersPanelBody({
353
420
  const isMe = me !== null && member.email?.toLowerCase() === me;
354
421
  const isOwner = member.role === "owner";
355
422
  const rowBusy = Boolean(busy[`member:${member.id}`]);
356
- const pickerRoles = roleOptions.includes(member.role) ? roleOptions : [member.role, ...roleOptions];
423
+ const grantable = grantableRoles(callerRole, member.role, roleOptions);
424
+ const pickerRoles = roleOptions.includes(member.role) ? roleOptions.filter((r) => r === member.role || grantable.includes(r)) : [member.role, ...grantable];
357
425
  return /* @__PURE__ */ jsxs(
358
426
  "li",
359
427
  {
@@ -383,7 +451,7 @@ function TeamMembersPanelBody({
383
451
  ] }),
384
452
  /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: [
385
453
  isOwner ? /* @__PURE__ */ jsx2("span", { className: roleBadgeClass(member.role), children: roleLabel(member.role) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
386
- /* @__PURE__ */ jsx2(
454
+ grantable.length === 0 ? /* @__PURE__ */ jsx2("span", { className: roleBadgeClass(member.role), children: roleLabel(member.role) }) : /* @__PURE__ */ jsx2(
387
455
  "select",
388
456
  {
389
457
  "aria-label": `Role for ${name}`,
@@ -444,7 +512,11 @@ function TeamMembersPanelBody({
444
512
  inv.invited_by_email ?? "a team member",
445
513
  " \xB7 expires ",
446
514
  formatDate(inv.expires_at)
447
- ] })
515
+ ] }),
516
+ inv.spaces && inv.spaces.length > 0 ? /* @__PURE__ */ jsxs("p", { className: "truncate text-xs text-muted-foreground", children: [
517
+ "Spaces: ",
518
+ inv.spaces.map((sp) => sp.name).join(", ")
519
+ ] }) : null
448
520
  ] }),
449
521
  /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [
450
522
  /* @__PURE__ */ jsx2("span", { className: roleBadgeClass(inv.role), children: roleLabel(inv.role) }),
@@ -469,7 +541,8 @@ function TeamMembersPanelBody({
469
541
  onClick: () => void handleRevoke(inv),
470
542
  children: "Revoke"
471
543
  }
472
- )
544
+ ),
545
+ renderInvitationActions?.(inv, { reload: load })
473
546
  ] })
474
547
  ]
475
548
  },
@@ -483,7 +556,7 @@ function TeamMembersPanelBody({
483
556
  Modal,
484
557
  {
485
558
  open: inviteOpen,
486
- onClose: () => setInviteOpen(false),
559
+ onClose: closeInvite,
487
560
  titleId: inviteTitleId,
488
561
  descriptionId: inviteDescId,
489
562
  dismissible: !inviteSubmitting,
@@ -530,6 +603,19 @@ function TeamMembersPanelBody({
530
603
  ),
531
604
  /* @__PURE__ */ jsx2("p", { className: "text-xs text-muted-foreground", children: INVITE_ROLE_OPTIONS[inviteRole]?.description })
532
605
  ] }),
606
+ inviteError ? /* @__PURE__ */ jsx2("p", { role: "alert", className: "text-sm text-destructive", children: inviteError }) : null,
607
+ loadSpaceOptions ? /* @__PURE__ */ jsx2(
608
+ InviteSpacesPicker,
609
+ {
610
+ state: spaceOptions,
611
+ selected: inviteSpaceIds,
612
+ selectionChanged: inviteSpacesChanged,
613
+ disabled: inviteSubmitting,
614
+ onToggle: (id, checked) => setInviteSpaceIds(
615
+ (prev) => checked ? [...prev.filter((x) => x !== id), id] : prev.filter((x) => x !== id)
616
+ )
617
+ }
618
+ ) : null,
533
619
  /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
534
620
  /* @__PURE__ */ jsx2(
535
621
  "button",
@@ -537,7 +623,7 @@ function TeamMembersPanelBody({
537
623
  type: "button",
538
624
  className: BUTTON_OUTLINE,
539
625
  disabled: inviteSubmitting,
540
- onClick: () => setInviteOpen(false),
626
+ onClick: closeInvite,
541
627
  children: "Cancel"
542
628
  }
543
629
  ),
@@ -546,7 +632,7 @@ function TeamMembersPanelBody({
546
632
  {
547
633
  type: "submit",
548
634
  className: BUTTON_PRIMARY,
549
- disabled: !inviteEmail.trim() || inviteSubmitting,
635
+ disabled: !inviteEmail.trim() || inviteSubmitting || inviteSpacesBlocked,
550
636
  children: inviteSubmitting ? "Sending\u2026" : "Send Invitation"
551
637
  }
552
638
  )
@@ -602,6 +688,57 @@ function TeamMembersPanelBody({
602
688
  )
603
689
  ] });
604
690
  }
691
+ function InviteSpacesPicker({
692
+ state,
693
+ selected,
694
+ selectionChanged,
695
+ disabled,
696
+ onToggle
697
+ }) {
698
+ const ids = useId();
699
+ if (state.status === "loading") {
700
+ return /* @__PURE__ */ jsx2("p", { className: "text-sm text-muted-foreground", children: "Loading spaces\u2026" });
701
+ }
702
+ if (state.status === "error") {
703
+ return /* @__PURE__ */ jsx2("p", { className: "text-sm text-destructive", children: selected.length > 0 ? "Couldn\u2019t load spaces, so the checked ones can\u2019t be sent. Cancel and reopen to retry." : "Couldn\u2019t load spaces, so the invite can\u2019t be sent yet. Cancel and reopen to retry." });
704
+ }
705
+ const { options } = state;
706
+ if (options.length === 1) {
707
+ return /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
708
+ "They\u2019ll get access to ",
709
+ options[0].name,
710
+ "."
711
+ ] });
712
+ }
713
+ if (options.length === 0) return null;
714
+ const picked = options.some((o) => o.can_manage && selected.includes(o.id));
715
+ const manageable = options.some((o) => o.can_manage);
716
+ return /* @__PURE__ */ jsxs("fieldset", { className: "space-y-2", disabled, "aria-labelledby": `${ids}-legend`, children: [
717
+ /* @__PURE__ */ jsx2("legend", { id: `${ids}-legend`, className: "text-sm font-medium", children: "Spaces" }),
718
+ /* @__PURE__ */ jsx2("p", { className: "text-xs text-muted-foreground", children: "The spaces they\u2019ll join." }),
719
+ selectionChanged ? /* @__PURE__ */ jsx2("p", { className: "text-sm text-destructive", children: "A checked space changed or is no longer manageable. Cancel and reopen to review the assignment before sending." }) : !manageable ? /* @__PURE__ */ jsx2("p", { className: "text-sm text-destructive", children: "You can\u2019t add people to any of these spaces. Ask an org owner or admin to send this invite." }) : !picked ? /* @__PURE__ */ jsx2("p", { className: "text-sm text-muted-foreground", children: "Pick at least one space." }) : null,
720
+ /* @__PURE__ */ jsx2("ul", { className: "max-h-48 divide-y divide-border overflow-y-auto rounded-md border border-border", children: options.map((o) => /* @__PURE__ */ jsx2("li", { children: /* @__PURE__ */ jsxs(
721
+ "label",
722
+ {
723
+ className: `flex items-center gap-3 px-3 py-2 text-sm ${o.can_manage ? "cursor-pointer" : "cursor-not-allowed text-muted-foreground"}`,
724
+ children: [
725
+ /* @__PURE__ */ jsx2(
726
+ "input",
727
+ {
728
+ type: "checkbox",
729
+ className: "accent-foreground",
730
+ checked: o.can_manage && selected.includes(o.id),
731
+ disabled: !o.can_manage,
732
+ onChange: (e) => onToggle(o.id, e.target.checked)
733
+ }
734
+ ),
735
+ /* @__PURE__ */ jsx2("span", { children: o.name }),
736
+ o.can_manage ? null : /* @__PURE__ */ jsx2("span", { className: "ml-auto text-xs", children: "You can't manage this space" })
737
+ ]
738
+ }
739
+ ) }, o.id)) })
740
+ ] });
741
+ }
605
742
  export {
606
743
  TeamMembersPanel
607
744
  };
package/dist/index.d.mts CHANGED
@@ -31,6 +31,38 @@ interface Invitation {
31
31
  /** ISO-8601. */
32
32
  expires_at: string;
33
33
  accepted_at: string | null;
34
+ /**
35
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
36
+ * The backend always sends it; optional so a backend that predates it
37
+ * still type-checks as "no pre-set spaces".
38
+ */
39
+ spaces?: InvitationSpace[];
40
+ }
41
+ /** A space role. */
42
+ type SpaceRole = "member" | "admin";
43
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
44
+ interface InvitationSpace {
45
+ id: string;
46
+ name: string;
47
+ role: SpaceRole;
48
+ }
49
+ /** One space an invite (or a pending invitation) should land the invitee in. */
50
+ interface InviteSpace {
51
+ space_id: string;
52
+ role: SpaceRole;
53
+ }
54
+ /**
55
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
56
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
57
+ * offered, the default one included: nobody joins a space automatically
58
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
59
+ */
60
+ interface SpaceOption {
61
+ id: string;
62
+ name: string;
63
+ kind?: string;
64
+ is_default: boolean;
65
+ can_manage: boolean;
34
66
  }
35
67
  /** `POST /api/org/invite`. */
36
68
  interface InviteMemberResponse {
@@ -50,7 +82,12 @@ interface TeamClient {
50
82
  listMembers(orgId: string): Promise<TeamMember[]>;
51
83
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
52
84
  removeMember(orgId: string, memberId: string): Promise<void>;
53
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
85
+ /**
86
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
87
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
88
+ * request.
89
+ */
90
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
54
91
  listInvitations(orgId: string): Promise<Invitation[]>;
55
92
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
56
93
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -68,7 +105,7 @@ interface TeamClient {
68
105
  * GET /api/org/members -> TeamMember[]
69
106
  * PUT /api/org/members/{id}/role {role} -> 2xx
70
107
  * DELETE /api/org/members/{id} -> 2xx
71
- * POST /api/org/invite {email, role} -> InviteMemberResponse
108
+ * POST /api/org/invite {email, role, spaces?} -> InviteMemberResponse
72
109
  * GET /api/org/invitations -> Invitation[]
73
110
  * DELETE /api/org/invitations/{id} -> 2xx
74
111
  * POST /api/org/invitations/{id}/resend -> 2xx (resets the 7-day expiry)
@@ -101,4 +138,4 @@ type TeamClientOptions = {
101
138
  };
102
139
  declare function createTeamClient({ fetch }: TeamClientOptions): TeamClient;
103
140
 
104
- export { type AssignableRole, type Invitation, type InviteMemberResponse, ORG_HEADER, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
141
+ export { type AssignableRole, type Invitation, type InvitationSpace, type InviteMemberResponse, type InviteSpace, ORG_HEADER, type SpaceOption, type SpaceRole, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
package/dist/index.d.ts CHANGED
@@ -31,6 +31,38 @@ interface Invitation {
31
31
  /** ISO-8601. */
32
32
  expires_at: string;
33
33
  accepted_at: string | null;
34
+ /**
35
+ * Spaces the invitee joins on accept (DEV-15988 / reeve-services DEV-15559).
36
+ * The backend always sends it; optional so a backend that predates it
37
+ * still type-checks as "no pre-set spaces".
38
+ */
39
+ spaces?: InvitationSpace[];
40
+ }
41
+ /** A space role. */
42
+ type SpaceRole = "member" | "admin";
43
+ /** One pre-set space on an invitation (`GET /api/org/invitations` item). */
44
+ interface InvitationSpace {
45
+ id: string;
46
+ name: string;
47
+ role: SpaceRole;
48
+ }
49
+ /** One space an invite (or a pending invitation) should land the invitee in. */
50
+ interface InviteSpace {
51
+ space_id: string;
52
+ role: SpaceRole;
53
+ }
54
+ /**
55
+ * One of the org's spaces, as the invite dialog's Spaces picker lists it
56
+ * (reeve-frontend maps `GET /api/org/rbac/spaces` onto this). Every space is
57
+ * offered, the default one included: nobody joins a space automatically
58
+ * (DEV-16808). `can_manage: false` rows are shown disabled.
59
+ */
60
+ interface SpaceOption {
61
+ id: string;
62
+ name: string;
63
+ kind?: string;
64
+ is_default: boolean;
65
+ can_manage: boolean;
34
66
  }
35
67
  /** `POST /api/org/invite`. */
36
68
  interface InviteMemberResponse {
@@ -50,7 +82,12 @@ interface TeamClient {
50
82
  listMembers(orgId: string): Promise<TeamMember[]>;
51
83
  updateMemberRole(orgId: string, memberId: string, role: string): Promise<void>;
52
84
  removeMember(orgId: string, memberId: string): Promise<void>;
53
- inviteMember(orgId: string, email: string, role: string): Promise<InviteMemberResponse>;
85
+ /**
86
+ * `spaces` goes in the body whenever it is passed, `[]` included (a
87
+ * one-space org sends `[]`, DEV-16808); omitted keeps the pre-DEV-15988
88
+ * request.
89
+ */
90
+ inviteMember(orgId: string, email: string, role: string, spaces?: InviteSpace[]): Promise<InviteMemberResponse>;
54
91
  listInvitations(orgId: string): Promise<Invitation[]>;
55
92
  revokeInvitation(orgId: string, invitationId: string): Promise<void>;
56
93
  resendInvitation(orgId: string, invitationId: string): Promise<void>;
@@ -68,7 +105,7 @@ interface TeamClient {
68
105
  * GET /api/org/members -> TeamMember[]
69
106
  * PUT /api/org/members/{id}/role {role} -> 2xx
70
107
  * DELETE /api/org/members/{id} -> 2xx
71
- * POST /api/org/invite {email, role} -> InviteMemberResponse
108
+ * POST /api/org/invite {email, role, spaces?} -> InviteMemberResponse
72
109
  * GET /api/org/invitations -> Invitation[]
73
110
  * DELETE /api/org/invitations/{id} -> 2xx
74
111
  * POST /api/org/invitations/{id}/resend -> 2xx (resets the 7-day expiry)
@@ -101,4 +138,4 @@ type TeamClientOptions = {
101
138
  };
102
139
  declare function createTeamClient({ fetch }: TeamClientOptions): TeamClient;
103
140
 
104
- export { type AssignableRole, type Invitation, type InviteMemberResponse, ORG_HEADER, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
141
+ export { type AssignableRole, type Invitation, type InvitationSpace, type InviteMemberResponse, type InviteSpace, ORG_HEADER, type SpaceOption, type SpaceRole, type TeamClient, type TeamClientOptions, type TeamFetch, type TeamMember, type TeamRequestInit, type TeamRole, createTeamClient };
package/dist/index.js CHANGED
@@ -73,12 +73,14 @@ function createTeamClient({ fetch }) {
73
73
  removeMember: (orgId, memberId) => send(orgId, "DELETE", `/api/org/members/${segment(memberId, "memberId")}`).then(
74
74
  () => void 0
75
75
  ),
76
- inviteMember: (orgId, email, role) => {
76
+ inviteMember: (orgId, email, role, spaces) => {
77
77
  if (typeof email !== "string" || email.trim() === "") {
78
78
  throw new Error("email must be a non-empty string");
79
79
  }
80
80
  segment(role, "role");
81
- return send(orgId, "POST", "/api/org/invite", { email: email.trim(), role }).then(
81
+ const body = { email: email.trim(), role };
82
+ if (spaces) body.spaces = spaces;
83
+ return send(orgId, "POST", "/api/org/invite", body).then(
82
84
  (d) => d
83
85
  );
84
86
  },
package/dist/index.mjs CHANGED
@@ -46,12 +46,14 @@ function createTeamClient({ fetch }) {
46
46
  removeMember: (orgId, memberId) => send(orgId, "DELETE", `/api/org/members/${segment(memberId, "memberId")}`).then(
47
47
  () => void 0
48
48
  ),
49
- inviteMember: (orgId, email, role) => {
49
+ inviteMember: (orgId, email, role, spaces) => {
50
50
  if (typeof email !== "string" || email.trim() === "") {
51
51
  throw new Error("email must be a non-empty string");
52
52
  }
53
53
  segment(role, "role");
54
- return send(orgId, "POST", "/api/org/invite", { email: email.trim(), role }).then(
54
+ const body = { email: email.trim(), role };
55
+ if (spaces) body.spaces = spaces;
56
+ return send(orgId, "POST", "/api/org/invite", body).then(
55
57
  (d) => d
56
58
  );
57
59
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetreeve/module-team",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Reeve.Team module (DEV-9998, C2 of epic DEV-9983): the CONFIG surface for an org's people - members table, role picker, remove member, invite dialog, pending invitations (revoke/resend) and seat count - plus a server-safe typed client over /api/org/*. Talks to the host through an injected fetch. Versioned in lockstep with the Python reeve-module-team package (both ship the same MAJOR.MINOR.PATCH).",
5
5
  "license": "UNLICENSED",
6
6
  "main": "./dist/index.js",