From 22eae7bb6008dafbe72cf6d20e844032e4f11185 Mon Sep 17 00:00:00 2001 From: cvince Date: Tue, 9 Jun 2026 13:01:45 -0700 Subject: [PATCH] fix(invite): honor --role when re-inviting an existing member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-inviting an existing member reused their current role and skipped role resolution, so an explicit --role (or interactive role change) was silently ignored — re-inviting to promote/demote was a no-op. It was also fragile across kick -> re-invite: if listMemberDetails read a not-yet-propagated membership, the stale role was kept. Take the silent re-issue path only when no role is requested (existingMember && !opts.role); otherwise resolve the role normally and let the service's addUserToOrgWithRole upgrade the membership. Keep the member's existing project scope by default on re-issue. Surfaced by the e2e protected-branch test: under the harness the CLI runs non-interactively (piped stdin), so the interactive role prompt never ran and re-invite-as-admin silently fell back to member. --- src/commands/inviteCommand.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/commands/inviteCommand.ts b/src/commands/inviteCommand.ts index d63956a..bbc0cb2 100644 --- a/src/commands/inviteCommand.ts +++ b/src/commands/inviteCommand.ts @@ -149,10 +149,17 @@ export class InviteCommand { let projectId: string | undefined; let extraProjectIds: string[] = []; const reissuing = !!existingMember; + const existingProjectIds = existingMember + ? (existingMember.projects || []).map((p) => p.id) + : []; - if (existingMember) { + // Pure re-issue (existing member, no explicit --role): reuse their current + // role + projects. But an explicit --role MUST be honored so admins can + // promote/demote on re-invite — and so a re-invite that races a just-issued + // `kick` (a not-yet-propagated member read) still applies the requested + // role instead of silently keeping the stale one. + if (existingMember && !opts.role) { role = existingMember.role; - const existingProjectIds = (existingMember.projects || []).map((p) => p.id); projectId = existingProjectIds[0]; extraProjectIds = existingProjectIds.slice(1); } else { @@ -220,9 +227,13 @@ export class InviteCommand { projectId = resolved[0]; extraProjectIds = resolved.slice(1); } else if (!interactive) { - // No --project: fall back to the cwd project, else refuse — we - // won't silently grant access to a project the caller didn't name. - if (cwdProjectId) { + // No --project: keep the member's existing projects on re-issue, else + // fall back to the cwd project, else refuse — we won't silently grant + // access to a project the caller didn't name. + if (reissuing && existingProjectIds.length > 0) { + projectId = existingProjectIds[0]; + extraProjectIds = existingProjectIds.slice(1); + } else if (cwdProjectId) { projectId = cwdProjectId; } else { refuseNonInteractive(