@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 +19 -3
- package/dist/client/index.d.mts +40 -3
- package/dist/client/index.d.ts +40 -3
- package/dist/client/index.js +4 -2
- package/dist/client/index.mjs +4 -2
- package/dist/config/index.d.mts +62 -3
- package/dist/config/index.d.ts +62 -3
- package/dist/config/index.js +154 -17
- package/dist/config/index.mjs +154 -17
- package/dist/index.d.mts +40 -3
- package/dist/index.d.ts +40 -3
- package/dist/index.js +4 -2
- package/dist/index.mjs +4 -2
- package/package.json +1 -1
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
|
|
58
|
-
|
|
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`,
|
package/dist/client/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
|
-
|
|
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/client/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
|
-
|
|
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/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|
package/dist/client/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
|
-
|
|
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/dist/config/index.d.mts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 };
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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 };
|
package/dist/config/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
301
|
+
closeInvite();
|
|
235
302
|
await load();
|
|
236
303
|
} catch (err) {
|
|
237
304
|
const { status, message } = errorInfo(err, "Failed to send invitation");
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
package/dist/config/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
276
|
+
closeInvite();
|
|
210
277
|
await load();
|
|
211
278
|
} catch (err) {
|
|
212
279
|
const { status, message } = errorInfo(err, "Failed to send invitation");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|