@indigoai-us/hq-cli 5.10.0 → 5.10.1
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/dist/commands/members.d.ts +53 -0
- package/dist/commands/members.js +240 -0
- package/dist/index.js +5 -2
- package/package.json +1 -1
- package/src/commands/members.test.ts +304 -0
- package/src/commands/members.ts +367 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
export declare const VALID_ROLES: Set<string>;
|
|
3
|
+
export type Role = "owner" | "admin" | "member" | "guest";
|
|
4
|
+
export interface PendingInvite {
|
|
5
|
+
membershipKey: string;
|
|
6
|
+
personUid?: string;
|
|
7
|
+
inviteeEmail?: string;
|
|
8
|
+
companyUid: string;
|
|
9
|
+
role: string;
|
|
10
|
+
status: string;
|
|
11
|
+
inviteToken?: string;
|
|
12
|
+
invitedBy: string;
|
|
13
|
+
invitedAt: string;
|
|
14
|
+
}
|
|
15
|
+
export interface InviteOptions {
|
|
16
|
+
target: string;
|
|
17
|
+
role: string;
|
|
18
|
+
paths?: string;
|
|
19
|
+
companyUid: string;
|
|
20
|
+
callerUid: string;
|
|
21
|
+
token: string;
|
|
22
|
+
}
|
|
23
|
+
export interface InviteResult {
|
|
24
|
+
inviteToken: string;
|
|
25
|
+
magicLink: string;
|
|
26
|
+
membership: {
|
|
27
|
+
role: string;
|
|
28
|
+
status: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface DetectedTarget {
|
|
32
|
+
type: "email" | "person";
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function detectTarget(target: string): DetectedTarget | null;
|
|
36
|
+
export declare function shortDate(iso: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the caller's personUid by reading their own membership list.
|
|
39
|
+
* The server infers the JWT identity, so this returns the canonical
|
|
40
|
+
* personUid attached to the caller's active memberships.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getCallerPersonUid(token: string): Promise<string>;
|
|
43
|
+
/** Send a `/membership/invite` request and return the magic link. */
|
|
44
|
+
export declare function inviteMember(options: InviteOptions): Promise<InviteResult>;
|
|
45
|
+
export declare class InviteHttpError extends Error {
|
|
46
|
+
status: number;
|
|
47
|
+
constructor(status: number, message: string);
|
|
48
|
+
}
|
|
49
|
+
export declare function formatInviteHttpError(status: number, fallback: string): string;
|
|
50
|
+
export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
|
|
51
|
+
export declare function revokeInvite(token: string, tokenOrKey: string, companyUid: string): Promise<void>;
|
|
52
|
+
export declare function registerMembersCommand(program: Command): void;
|
|
53
|
+
//# sourceMappingURL=members.d.ts.map
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1b3646e3-32c8-5601-9dba-1d7c0f6cf972")}catch(e){}}();
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
6
|
+
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
7
|
+
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
8
|
+
export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
9
|
+
export function detectTarget(target) {
|
|
10
|
+
if (EMAIL_PATTERN.test(target)) {
|
|
11
|
+
return { type: "email", value: target.trim().toLowerCase() };
|
|
12
|
+
}
|
|
13
|
+
if (PERSON_UID_PATTERN.test(target)) {
|
|
14
|
+
return { type: "person", value: target };
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export function shortDate(iso) {
|
|
19
|
+
return iso.slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the caller's personUid by reading their own membership list.
|
|
23
|
+
* The server infers the JWT identity, so this returns the canonical
|
|
24
|
+
* personUid attached to the caller's active memberships.
|
|
25
|
+
*/
|
|
26
|
+
export async function getCallerPersonUid(token) {
|
|
27
|
+
const res = await vaultApiFetch({ token, path: "/membership/me" });
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error("Failed to resolve caller identity — run `hq login` and try again");
|
|
30
|
+
}
|
|
31
|
+
const data = (await res.json());
|
|
32
|
+
const personUid = data.memberships.find((m) => m.personUid)?.personUid;
|
|
33
|
+
if (!personUid) {
|
|
34
|
+
throw new Error("Your account has no person entity yet. Run `hq onboard create-company` or accept an invite first.");
|
|
35
|
+
}
|
|
36
|
+
return personUid;
|
|
37
|
+
}
|
|
38
|
+
/** Send a `/membership/invite` request and return the magic link. */
|
|
39
|
+
export async function inviteMember(options) {
|
|
40
|
+
if (!VALID_ROLES.has(options.role)) {
|
|
41
|
+
throw new Error(`Invalid role '${options.role}': must be one of owner, admin, member, guest`);
|
|
42
|
+
}
|
|
43
|
+
if (options.paths && options.role !== "guest") {
|
|
44
|
+
throw new Error("--paths is only valid with --role guest (allowedPrefixes are only meaningful for the guest role)");
|
|
45
|
+
}
|
|
46
|
+
const detected = detectTarget(options.target);
|
|
47
|
+
if (!detected) {
|
|
48
|
+
throw new Error(`Invalid target '${options.target}': must be an email address or a personUid matching prs_<alphanumeric>`);
|
|
49
|
+
}
|
|
50
|
+
const allowedPrefixes = options.paths
|
|
51
|
+
? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
|
|
52
|
+
: undefined;
|
|
53
|
+
const body = {
|
|
54
|
+
companyUid: options.companyUid,
|
|
55
|
+
role: options.role,
|
|
56
|
+
invitedBy: options.callerUid,
|
|
57
|
+
};
|
|
58
|
+
if (detected.type === "email")
|
|
59
|
+
body.inviteeEmail = detected.value;
|
|
60
|
+
else
|
|
61
|
+
body.personUid = detected.value;
|
|
62
|
+
if (allowedPrefixes)
|
|
63
|
+
body.allowedPrefixes = allowedPrefixes;
|
|
64
|
+
const res = await vaultApiFetch({
|
|
65
|
+
token: options.token,
|
|
66
|
+
path: "/membership/invite",
|
|
67
|
+
method: "POST",
|
|
68
|
+
body,
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
const err = (await res.json().catch(() => ({})));
|
|
72
|
+
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
|
|
73
|
+
}
|
|
74
|
+
const data = (await res.json());
|
|
75
|
+
return {
|
|
76
|
+
inviteToken: data.inviteToken,
|
|
77
|
+
magicLink: `hq://accept/${data.inviteToken}`,
|
|
78
|
+
membership: data.membership,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export class InviteHttpError extends Error {
|
|
82
|
+
status;
|
|
83
|
+
constructor(status, message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.name = "InviteHttpError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function formatInviteHttpError(status, fallback) {
|
|
90
|
+
if (status === 401)
|
|
91
|
+
return "Not authenticated — please run `hq login`";
|
|
92
|
+
if (status === 403) {
|
|
93
|
+
return "Not authorized — only admins and owners can invite members";
|
|
94
|
+
}
|
|
95
|
+
if (status === 409) {
|
|
96
|
+
return "This person already has a membership or pending invite for this company";
|
|
97
|
+
}
|
|
98
|
+
if (status >= 500)
|
|
99
|
+
return `Server error: ${fallback}`;
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
export async function listPendingInvites(token, companyUid) {
|
|
103
|
+
const res = await vaultApiFetch({
|
|
104
|
+
token,
|
|
105
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}/pending`,
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const err = (await res.json().catch(() => ({})));
|
|
109
|
+
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
|
|
110
|
+
}
|
|
111
|
+
const data = (await res.json());
|
|
112
|
+
return data.invites;
|
|
113
|
+
}
|
|
114
|
+
export async function revokeInvite(token, tokenOrKey, companyUid) {
|
|
115
|
+
const res = await vaultApiFetch({
|
|
116
|
+
token,
|
|
117
|
+
path: "/membership/revoke",
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: { membershipKey: tokenOrKey, companyUid },
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const err = (await res.json().catch(() => ({})));
|
|
123
|
+
throw new InviteHttpError(res.status, err.message ?? err.error ?? res.statusText);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function registerMembersCommand(program) {
|
|
127
|
+
const members = program
|
|
128
|
+
.command("members")
|
|
129
|
+
.description("Manage company memberships and invites")
|
|
130
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
131
|
+
members
|
|
132
|
+
.command("invite <target>")
|
|
133
|
+
.description("Invite a person to the company by email or personUid (prints a magic link)")
|
|
134
|
+
.option("--role <role>", "Role for the invitee: owner, admin, member, or guest", "member")
|
|
135
|
+
.option("--paths <prefixes>", "Comma-separated allowed prefixes (only valid with --role guest)")
|
|
136
|
+
.action(async (target, opts) => {
|
|
137
|
+
try {
|
|
138
|
+
const token = await ensureCognitoToken();
|
|
139
|
+
const companySlug = members.opts().company;
|
|
140
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
141
|
+
const callerUid = await getCallerPersonUid(token);
|
|
142
|
+
const result = await inviteMember({
|
|
143
|
+
target,
|
|
144
|
+
role: opts.role,
|
|
145
|
+
paths: opts.paths,
|
|
146
|
+
companyUid,
|
|
147
|
+
callerUid,
|
|
148
|
+
token,
|
|
149
|
+
});
|
|
150
|
+
console.log(chalk.green(`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`));
|
|
151
|
+
console.log();
|
|
152
|
+
console.log(chalk.bold("Magic link:"));
|
|
153
|
+
console.log(` ${result.magicLink}`);
|
|
154
|
+
console.log();
|
|
155
|
+
console.log(chalk.dim("Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept."));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err instanceof InviteHttpError) {
|
|
159
|
+
console.error(chalk.red(formatInviteHttpError(err.status, err.message)));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
members
|
|
167
|
+
.command("list")
|
|
168
|
+
.description("List pending invites for the company")
|
|
169
|
+
.action(async () => {
|
|
170
|
+
try {
|
|
171
|
+
const token = await ensureCognitoToken();
|
|
172
|
+
const companySlug = members.opts().company;
|
|
173
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
174
|
+
const invites = await listPendingInvites(token, companyUid);
|
|
175
|
+
if (invites.length === 0) {
|
|
176
|
+
console.log(chalk.gray("No pending invites for this company."));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const targetW = Math.max(6, ...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length));
|
|
180
|
+
const roleW = Math.max(4, ...invites.map((i) => i.role.length));
|
|
181
|
+
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
182
|
+
const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
|
|
183
|
+
console.log(chalk.bold([
|
|
184
|
+
"TARGET".padEnd(targetW),
|
|
185
|
+
"ROLE".padEnd(roleW),
|
|
186
|
+
"INVITED_BY".padEnd(byW),
|
|
187
|
+
"INVITED_AT",
|
|
188
|
+
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
189
|
+
].join(" ")));
|
|
190
|
+
for (const inv of invites) {
|
|
191
|
+
const target = inv.inviteeEmail ?? inv.personUid ?? "";
|
|
192
|
+
console.log([
|
|
193
|
+
target.padEnd(targetW),
|
|
194
|
+
inv.role.padEnd(roleW),
|
|
195
|
+
inv.invitedBy.padEnd(byW),
|
|
196
|
+
shortDate(inv.invitedAt),
|
|
197
|
+
inv.membershipKey.padEnd(keyW),
|
|
198
|
+
].join(" "));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (err instanceof InviteHttpError) {
|
|
203
|
+
const msg = err.status === 403
|
|
204
|
+
? "Not authorized — only admins and owners can list invites"
|
|
205
|
+
: formatInviteHttpError(err.status, err.message);
|
|
206
|
+
console.error(chalk.red(msg));
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
members
|
|
214
|
+
.command("revoke <tokenOrKey>")
|
|
215
|
+
.description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
|
|
216
|
+
.action(async (tokenOrKey) => {
|
|
217
|
+
try {
|
|
218
|
+
const token = await ensureCognitoToken();
|
|
219
|
+
const companySlug = members.opts().company;
|
|
220
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
221
|
+
await revokeInvite(token, tokenOrKey, companyUid);
|
|
222
|
+
console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (err instanceof InviteHttpError) {
|
|
226
|
+
const msg = err.status === 403
|
|
227
|
+
? "Not authorized — only admins and owners can revoke invites"
|
|
228
|
+
: err.status === 404
|
|
229
|
+
? "Invite not found — it may have already been accepted or revoked"
|
|
230
|
+
: formatInviteHttpError(err.status, err.message);
|
|
231
|
+
console.error(chalk.red(msg));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=members.js.map
|
|
240
|
+
//# debugId=1b3646e3-32c8-5601-9dba-1d7c0f6cf972
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
6
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e834c407-cd5c-5dc1-af3b-7f041af651ef")}catch(e){}}();
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { initSentry, Sentry } from "./sentry.js";
|
|
9
9
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -27,6 +27,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
|
|
|
27
27
|
import { registerRunCommand } from "./commands/run.js";
|
|
28
28
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
|
+
import { registerMembersCommand } from "./commands/members.js";
|
|
30
31
|
initSentry();
|
|
31
32
|
const program = new Command();
|
|
32
33
|
program
|
|
@@ -81,6 +82,8 @@ registerRunCommand(program);
|
|
|
81
82
|
registerGroupsCommand(program);
|
|
82
83
|
// Files ACL management (subcommand group — hq files share|unshare|acl)
|
|
83
84
|
registerFilesCommand(program);
|
|
85
|
+
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
86
|
+
registerMembersCommand(program);
|
|
84
87
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
85
88
|
registerOnboardCommand(program);
|
|
86
89
|
(async () => {
|
|
@@ -96,4 +99,4 @@ registerOnboardCommand(program);
|
|
|
96
99
|
}
|
|
97
100
|
})();
|
|
98
101
|
//# sourceMappingURL=index.js.map
|
|
99
|
-
//# debugId=
|
|
102
|
+
//# debugId=e834c407-cd5c-5dc1-af3b-7f041af651ef
|
package/package.json
CHANGED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq members invite|list|revoke` (members.ts).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - detectTarget — pure validation for email vs personUid vs invalid
|
|
6
|
+
* - getCallerPersonUid — happy path + missing-person-entity branch
|
|
7
|
+
* - inviteMember — email + personUid targets, --paths gating, HTTP errors
|
|
8
|
+
* - listPendingInvites — happy path + 403
|
|
9
|
+
* - revokeInvite — happy path + 404
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
InviteHttpError,
|
|
16
|
+
detectTarget,
|
|
17
|
+
formatInviteHttpError,
|
|
18
|
+
getCallerPersonUid,
|
|
19
|
+
inviteMember,
|
|
20
|
+
listPendingInvites,
|
|
21
|
+
revokeInvite,
|
|
22
|
+
} from "./members.js";
|
|
23
|
+
|
|
24
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
25
|
+
return new Response(JSON.stringify(body), {
|
|
26
|
+
status,
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// detectTarget
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
describe("detectTarget", () => {
|
|
46
|
+
it("recognizes plain emails and lowercases them", () => {
|
|
47
|
+
expect(detectTarget("Alice@Example.com")).toEqual({
|
|
48
|
+
type: "email",
|
|
49
|
+
value: "alice@example.com",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("recognizes person UIDs", () => {
|
|
54
|
+
expect(detectTarget("prs_bob123")).toEqual({
|
|
55
|
+
type: "person",
|
|
56
|
+
value: "prs_bob123",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for invalid targets", () => {
|
|
61
|
+
expect(detectTarget("not-a-target")).toBeNull();
|
|
62
|
+
expect(detectTarget("cmp_company")).toBeNull();
|
|
63
|
+
expect(detectTarget("")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// getCallerPersonUid
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe("getCallerPersonUid", () => {
|
|
72
|
+
it("returns the personUid from the first membership", async () => {
|
|
73
|
+
fetchSpy.mockResolvedValueOnce(
|
|
74
|
+
jsonResponse(200, {
|
|
75
|
+
memberships: [
|
|
76
|
+
{ membershipKey: "k1", personUid: "prs_admin", companyUid: "cmp_a", role: "owner", status: "active" },
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const uid = await getCallerPersonUid("test-token");
|
|
82
|
+
expect(uid).toBe("prs_admin");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("throws if the caller has no person entity yet", async () => {
|
|
86
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { memberships: [] }));
|
|
87
|
+
await expect(getCallerPersonUid("test-token")).rejects.toThrow(/no person entity/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws on auth failure", async () => {
|
|
91
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(401, { error: "unauthorized" }));
|
|
92
|
+
await expect(getCallerPersonUid("test-token")).rejects.toThrow(/run `hq login`/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// inviteMember
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("inviteMember", () => {
|
|
101
|
+
it("creates an invite for an email target and returns a magic link", async () => {
|
|
102
|
+
fetchSpy.mockResolvedValueOnce(
|
|
103
|
+
jsonResponse(200, {
|
|
104
|
+
membership: { role: "member", status: "pending" },
|
|
105
|
+
inviteToken: "tok_abc",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = await inviteMember({
|
|
110
|
+
target: "alice@example.com",
|
|
111
|
+
role: "member",
|
|
112
|
+
companyUid: "cmp_acme",
|
|
113
|
+
callerUid: "prs_admin",
|
|
114
|
+
token: "test-token",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.magicLink).toBe("hq://accept/tok_abc");
|
|
118
|
+
expect(result.membership.role).toBe("member");
|
|
119
|
+
|
|
120
|
+
const call = fetchSpy.mock.calls[0];
|
|
121
|
+
const body = JSON.parse((call[1]?.body as string) ?? "{}");
|
|
122
|
+
expect(body).toEqual({
|
|
123
|
+
companyUid: "cmp_acme",
|
|
124
|
+
role: "member",
|
|
125
|
+
invitedBy: "prs_admin",
|
|
126
|
+
inviteeEmail: "alice@example.com",
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("creates an invite for a personUid target", async () => {
|
|
131
|
+
fetchSpy.mockResolvedValueOnce(
|
|
132
|
+
jsonResponse(200, {
|
|
133
|
+
membership: { role: "admin", status: "pending" },
|
|
134
|
+
inviteToken: "tok_456",
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await inviteMember({
|
|
139
|
+
target: "prs_bob",
|
|
140
|
+
role: "admin",
|
|
141
|
+
companyUid: "cmp_acme",
|
|
142
|
+
callerUid: "prs_admin",
|
|
143
|
+
token: "test-token",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
147
|
+
expect(body.personUid).toBe("prs_bob");
|
|
148
|
+
expect(body.inviteeEmail).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("forwards allowedPrefixes when --paths is set with --role guest", async () => {
|
|
152
|
+
fetchSpy.mockResolvedValueOnce(
|
|
153
|
+
jsonResponse(200, {
|
|
154
|
+
membership: { role: "guest", status: "pending" },
|
|
155
|
+
inviteToken: "tok_guest",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await inviteMember({
|
|
160
|
+
target: "alice@example.com",
|
|
161
|
+
role: "guest",
|
|
162
|
+
paths: "docs/, shared/",
|
|
163
|
+
companyUid: "cmp_acme",
|
|
164
|
+
callerUid: "prs_admin",
|
|
165
|
+
token: "test-token",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
169
|
+
expect(body.allowedPrefixes).toEqual(["docs/", "shared/"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rejects --paths with a non-guest role", async () => {
|
|
173
|
+
await expect(
|
|
174
|
+
inviteMember({
|
|
175
|
+
target: "alice@example.com",
|
|
176
|
+
role: "member",
|
|
177
|
+
paths: "docs/",
|
|
178
|
+
companyUid: "cmp_acme",
|
|
179
|
+
callerUid: "prs_admin",
|
|
180
|
+
token: "test-token",
|
|
181
|
+
}),
|
|
182
|
+
).rejects.toThrow(/--paths is only valid with --role guest/);
|
|
183
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects an invalid target", async () => {
|
|
187
|
+
await expect(
|
|
188
|
+
inviteMember({
|
|
189
|
+
target: "not-a-target",
|
|
190
|
+
role: "member",
|
|
191
|
+
companyUid: "cmp_acme",
|
|
192
|
+
callerUid: "prs_admin",
|
|
193
|
+
token: "test-token",
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toThrow(/Invalid target/);
|
|
196
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects an unknown role", async () => {
|
|
200
|
+
await expect(
|
|
201
|
+
inviteMember({
|
|
202
|
+
target: "alice@example.com",
|
|
203
|
+
role: "superuser",
|
|
204
|
+
companyUid: "cmp_acme",
|
|
205
|
+
callerUid: "prs_admin",
|
|
206
|
+
token: "test-token",
|
|
207
|
+
}),
|
|
208
|
+
).rejects.toThrow(/Invalid role/);
|
|
209
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("wraps non-2xx responses in InviteHttpError", async () => {
|
|
213
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(409, { error: "duplicate" }));
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
inviteMember({
|
|
217
|
+
target: "alice@example.com",
|
|
218
|
+
role: "member",
|
|
219
|
+
companyUid: "cmp_acme",
|
|
220
|
+
callerUid: "prs_admin",
|
|
221
|
+
token: "test-token",
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// listPendingInvites
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
describe("listPendingInvites", () => {
|
|
232
|
+
it("returns the parsed invites array", async () => {
|
|
233
|
+
fetchSpy.mockResolvedValueOnce(
|
|
234
|
+
jsonResponse(200, {
|
|
235
|
+
invites: [
|
|
236
|
+
{
|
|
237
|
+
membershipKey: "k1",
|
|
238
|
+
inviteeEmail: "alice@example.com",
|
|
239
|
+
companyUid: "cmp_acme",
|
|
240
|
+
role: "member",
|
|
241
|
+
status: "pending",
|
|
242
|
+
invitedBy: "prs_admin",
|
|
243
|
+
invitedAt: "2026-05-04T12:00:00Z",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const invites = await listPendingInvites("test-token", "cmp_acme");
|
|
250
|
+
expect(invites).toHaveLength(1);
|
|
251
|
+
expect(invites[0].inviteeEmail).toBe("alice@example.com");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("throws InviteHttpError on 403", async () => {
|
|
255
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
|
|
256
|
+
await expect(listPendingInvites("test-token", "cmp_acme")).rejects.toBeInstanceOf(
|
|
257
|
+
InviteHttpError,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// revokeInvite
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
describe("revokeInvite", () => {
|
|
267
|
+
it("posts membershipKey + companyUid", async () => {
|
|
268
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
269
|
+
|
|
270
|
+
await revokeInvite("test-token", "k1", "cmp_acme");
|
|
271
|
+
|
|
272
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
273
|
+
expect(body).toEqual({ membershipKey: "k1", companyUid: "cmp_acme" });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("throws InviteHttpError on 404", async () => {
|
|
277
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(404, { error: "not found" }));
|
|
278
|
+
await expect(revokeInvite("test-token", "k1", "cmp_acme")).rejects.toBeInstanceOf(
|
|
279
|
+
InviteHttpError,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// formatInviteHttpError
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
describe("formatInviteHttpError", () => {
|
|
289
|
+
it("maps 401 to a login hint", () => {
|
|
290
|
+
expect(formatInviteHttpError(401, "ignored")).toMatch(/run `hq login`/);
|
|
291
|
+
});
|
|
292
|
+
it("maps 403 to admin/owner hint", () => {
|
|
293
|
+
expect(formatInviteHttpError(403, "ignored")).toMatch(/admins and owners/);
|
|
294
|
+
});
|
|
295
|
+
it("maps 409 to duplicate-invite hint", () => {
|
|
296
|
+
expect(formatInviteHttpError(409, "ignored")).toMatch(/already has a membership/);
|
|
297
|
+
});
|
|
298
|
+
it("prefixes 5xx with 'Server error:'", () => {
|
|
299
|
+
expect(formatInviteHttpError(500, "boom")).toBe("Server error: boom");
|
|
300
|
+
});
|
|
301
|
+
it("falls through to the message for unmapped statuses", () => {
|
|
302
|
+
expect(formatInviteHttpError(400, "bad input")).toBe("bad input");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
5
|
+
|
|
6
|
+
const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
|
|
7
|
+
const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
|
|
8
|
+
export const VALID_ROLES = new Set(["owner", "admin", "member", "guest"]);
|
|
9
|
+
|
|
10
|
+
export type Role = "owner" | "admin" | "member" | "guest";
|
|
11
|
+
|
|
12
|
+
export interface PendingInvite {
|
|
13
|
+
membershipKey: string;
|
|
14
|
+
personUid?: string;
|
|
15
|
+
inviteeEmail?: string;
|
|
16
|
+
companyUid: string;
|
|
17
|
+
role: string;
|
|
18
|
+
status: string;
|
|
19
|
+
inviteToken?: string;
|
|
20
|
+
invitedBy: string;
|
|
21
|
+
invitedAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MyMembership {
|
|
25
|
+
membershipKey: string;
|
|
26
|
+
personUid: string;
|
|
27
|
+
companyUid: string;
|
|
28
|
+
role: string;
|
|
29
|
+
status: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InviteOptions {
|
|
33
|
+
target: string;
|
|
34
|
+
role: string;
|
|
35
|
+
paths?: string;
|
|
36
|
+
companyUid: string;
|
|
37
|
+
callerUid: string;
|
|
38
|
+
token: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InviteResult {
|
|
42
|
+
inviteToken: string;
|
|
43
|
+
magicLink: string;
|
|
44
|
+
membership: { role: string; status: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DetectedTarget {
|
|
48
|
+
type: "email" | "person";
|
|
49
|
+
value: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectTarget(target: string): DetectedTarget | null {
|
|
53
|
+
if (EMAIL_PATTERN.test(target)) {
|
|
54
|
+
return { type: "email", value: target.trim().toLowerCase() };
|
|
55
|
+
}
|
|
56
|
+
if (PERSON_UID_PATTERN.test(target)) {
|
|
57
|
+
return { type: "person", value: target };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function shortDate(iso: string): string {
|
|
63
|
+
return iso.slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the caller's personUid by reading their own membership list.
|
|
68
|
+
* The server infers the JWT identity, so this returns the canonical
|
|
69
|
+
* personUid attached to the caller's active memberships.
|
|
70
|
+
*/
|
|
71
|
+
export async function getCallerPersonUid(token: string): Promise<string> {
|
|
72
|
+
const res = await vaultApiFetch({ token, path: "/membership/me" });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"Failed to resolve caller identity — run `hq login` and try again",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const data = (await res.json()) as { memberships: MyMembership[] };
|
|
79
|
+
const personUid = data.memberships.find((m) => m.personUid)?.personUid;
|
|
80
|
+
if (!personUid) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"Your account has no person entity yet. Run `hq onboard create-company` or accept an invite first.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return personUid;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Send a `/membership/invite` request and return the magic link. */
|
|
89
|
+
export async function inviteMember(
|
|
90
|
+
options: InviteOptions,
|
|
91
|
+
): Promise<InviteResult> {
|
|
92
|
+
if (!VALID_ROLES.has(options.role)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Invalid role '${options.role}': must be one of owner, admin, member, guest`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (options.paths && options.role !== "guest") {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"--paths is only valid with --role guest (allowedPrefixes are only meaningful for the guest role)",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const detected = detectTarget(options.target);
|
|
104
|
+
if (!detected) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Invalid target '${options.target}': must be an email address or a personUid matching prs_<alphanumeric>`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const allowedPrefixes = options.paths
|
|
111
|
+
? options.paths.split(",").map((p) => p.trim()).filter(Boolean)
|
|
112
|
+
: undefined;
|
|
113
|
+
|
|
114
|
+
const body: Record<string, unknown> = {
|
|
115
|
+
companyUid: options.companyUid,
|
|
116
|
+
role: options.role,
|
|
117
|
+
invitedBy: options.callerUid,
|
|
118
|
+
};
|
|
119
|
+
if (detected.type === "email") body.inviteeEmail = detected.value;
|
|
120
|
+
else body.personUid = detected.value;
|
|
121
|
+
if (allowedPrefixes) body.allowedPrefixes = allowedPrefixes;
|
|
122
|
+
|
|
123
|
+
const res = await vaultApiFetch({
|
|
124
|
+
token: options.token,
|
|
125
|
+
path: "/membership/invite",
|
|
126
|
+
method: "POST",
|
|
127
|
+
body,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
132
|
+
throw new InviteHttpError(
|
|
133
|
+
res.status,
|
|
134
|
+
err.message ?? err.error ?? res.statusText,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const data = (await res.json()) as {
|
|
139
|
+
membership: { role: string; status: string };
|
|
140
|
+
inviteToken: string;
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
inviteToken: data.inviteToken,
|
|
144
|
+
magicLink: `hq://accept/${data.inviteToken}`,
|
|
145
|
+
membership: data.membership,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class InviteHttpError extends Error {
|
|
150
|
+
constructor(public status: number, message: string) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.name = "InviteHttpError";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatInviteHttpError(status: number, fallback: string): string {
|
|
157
|
+
if (status === 401) return "Not authenticated — please run `hq login`";
|
|
158
|
+
if (status === 403) {
|
|
159
|
+
return "Not authorized — only admins and owners can invite members";
|
|
160
|
+
}
|
|
161
|
+
if (status === 409) {
|
|
162
|
+
return "This person already has a membership or pending invite for this company";
|
|
163
|
+
}
|
|
164
|
+
if (status >= 500) return `Server error: ${fallback}`;
|
|
165
|
+
return fallback;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function listPendingInvites(
|
|
169
|
+
token: string,
|
|
170
|
+
companyUid: string,
|
|
171
|
+
): Promise<PendingInvite[]> {
|
|
172
|
+
const res = await vaultApiFetch({
|
|
173
|
+
token,
|
|
174
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}/pending`,
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
178
|
+
throw new InviteHttpError(
|
|
179
|
+
res.status,
|
|
180
|
+
err.message ?? err.error ?? res.statusText,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const data = (await res.json()) as { invites: PendingInvite[] };
|
|
184
|
+
return data.invites;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function revokeInvite(
|
|
188
|
+
token: string,
|
|
189
|
+
tokenOrKey: string,
|
|
190
|
+
companyUid: string,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const res = await vaultApiFetch({
|
|
193
|
+
token,
|
|
194
|
+
path: "/membership/revoke",
|
|
195
|
+
method: "POST",
|
|
196
|
+
body: { membershipKey: tokenOrKey, companyUid },
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
200
|
+
throw new InviteHttpError(
|
|
201
|
+
res.status,
|
|
202
|
+
err.message ?? err.error ?? res.statusText,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function registerMembersCommand(program: Command): void {
|
|
208
|
+
const members = program
|
|
209
|
+
.command("members")
|
|
210
|
+
.description("Manage company memberships and invites")
|
|
211
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
212
|
+
|
|
213
|
+
members
|
|
214
|
+
.command("invite <target>")
|
|
215
|
+
.description(
|
|
216
|
+
"Invite a person to the company by email or personUid (prints a magic link)",
|
|
217
|
+
)
|
|
218
|
+
.option(
|
|
219
|
+
"--role <role>",
|
|
220
|
+
"Role for the invitee: owner, admin, member, or guest",
|
|
221
|
+
"member",
|
|
222
|
+
)
|
|
223
|
+
.option(
|
|
224
|
+
"--paths <prefixes>",
|
|
225
|
+
"Comma-separated allowed prefixes (only valid with --role guest)",
|
|
226
|
+
)
|
|
227
|
+
.action(
|
|
228
|
+
async (
|
|
229
|
+
target: string,
|
|
230
|
+
opts: { role: string; paths?: string },
|
|
231
|
+
) => {
|
|
232
|
+
try {
|
|
233
|
+
const token = await ensureCognitoToken();
|
|
234
|
+
const companySlug = members.opts().company as string | undefined;
|
|
235
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
236
|
+
const callerUid = await getCallerPersonUid(token);
|
|
237
|
+
|
|
238
|
+
const result = await inviteMember({
|
|
239
|
+
target,
|
|
240
|
+
role: opts.role,
|
|
241
|
+
paths: opts.paths,
|
|
242
|
+
companyUid,
|
|
243
|
+
callerUid,
|
|
244
|
+
token,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
console.log(
|
|
248
|
+
chalk.green(
|
|
249
|
+
`Invited ${target} as ${result.membership.role} (status: ${result.membership.status})`,
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
console.log();
|
|
253
|
+
console.log(chalk.bold("Magic link:"));
|
|
254
|
+
console.log(` ${result.magicLink}`);
|
|
255
|
+
console.log();
|
|
256
|
+
console.log(
|
|
257
|
+
chalk.dim(
|
|
258
|
+
"Share this link with the invitee. They can run `hq onboard join --invite-token <token>` to accept.",
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (err instanceof InviteHttpError) {
|
|
263
|
+
console.error(chalk.red(formatInviteHttpError(err.status, err.message)));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
console.error(
|
|
267
|
+
chalk.red("Error:"),
|
|
268
|
+
err instanceof Error ? err.message : String(err),
|
|
269
|
+
);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
members
|
|
276
|
+
.command("list")
|
|
277
|
+
.description("List pending invites for the company")
|
|
278
|
+
.action(async () => {
|
|
279
|
+
try {
|
|
280
|
+
const token = await ensureCognitoToken();
|
|
281
|
+
const companySlug = members.opts().company as string | undefined;
|
|
282
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
283
|
+
|
|
284
|
+
const invites = await listPendingInvites(token, companyUid);
|
|
285
|
+
|
|
286
|
+
if (invites.length === 0) {
|
|
287
|
+
console.log(chalk.gray("No pending invites for this company."));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const targetW = Math.max(
|
|
292
|
+
6,
|
|
293
|
+
...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
|
|
294
|
+
);
|
|
295
|
+
const roleW = Math.max(4, ...invites.map((i) => i.role.length));
|
|
296
|
+
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
297
|
+
const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
|
|
298
|
+
console.log(
|
|
299
|
+
chalk.bold(
|
|
300
|
+
[
|
|
301
|
+
"TARGET".padEnd(targetW),
|
|
302
|
+
"ROLE".padEnd(roleW),
|
|
303
|
+
"INVITED_BY".padEnd(byW),
|
|
304
|
+
"INVITED_AT",
|
|
305
|
+
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
306
|
+
].join(" "),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
for (const inv of invites) {
|
|
310
|
+
const target = inv.inviteeEmail ?? inv.personUid ?? "";
|
|
311
|
+
console.log(
|
|
312
|
+
[
|
|
313
|
+
target.padEnd(targetW),
|
|
314
|
+
inv.role.padEnd(roleW),
|
|
315
|
+
inv.invitedBy.padEnd(byW),
|
|
316
|
+
shortDate(inv.invitedAt),
|
|
317
|
+
inv.membershipKey.padEnd(keyW),
|
|
318
|
+
].join(" "),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err instanceof InviteHttpError) {
|
|
323
|
+
const msg =
|
|
324
|
+
err.status === 403
|
|
325
|
+
? "Not authorized — only admins and owners can list invites"
|
|
326
|
+
: formatInviteHttpError(err.status, err.message);
|
|
327
|
+
console.error(chalk.red(msg));
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
console.error(
|
|
331
|
+
chalk.red("Error:"),
|
|
332
|
+
err instanceof Error ? err.message : String(err),
|
|
333
|
+
);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
members
|
|
339
|
+
.command("revoke <tokenOrKey>")
|
|
340
|
+
.description("Revoke a pending invite (accepts the inviteToken or membershipKey)")
|
|
341
|
+
.action(async (tokenOrKey: string) => {
|
|
342
|
+
try {
|
|
343
|
+
const token = await ensureCognitoToken();
|
|
344
|
+
const companySlug = members.opts().company as string | undefined;
|
|
345
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
346
|
+
|
|
347
|
+
await revokeInvite(token, tokenOrKey, companyUid);
|
|
348
|
+
console.log(chalk.green(`Revoked invite '${tokenOrKey}'`));
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err instanceof InviteHttpError) {
|
|
351
|
+
const msg =
|
|
352
|
+
err.status === 403
|
|
353
|
+
? "Not authorized — only admins and owners can revoke invites"
|
|
354
|
+
: err.status === 404
|
|
355
|
+
? "Invite not found — it may have already been accepted or revoked"
|
|
356
|
+
: formatInviteHttpError(err.status, err.message);
|
|
357
|
+
console.error(chalk.red(msg));
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
console.error(
|
|
361
|
+
chalk.red("Error:"),
|
|
362
|
+
err instanceof Error ? err.message : String(err),
|
|
363
|
+
);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { registerSecretsCommand } from "./commands/secrets.js";
|
|
|
27
27
|
import { registerRunCommand } from "./commands/run.js";
|
|
28
28
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
|
+
import { registerMembersCommand } from "./commands/members.js";
|
|
30
31
|
|
|
31
32
|
initSentry();
|
|
32
33
|
|
|
@@ -102,6 +103,9 @@ registerGroupsCommand(program);
|
|
|
102
103
|
// Files ACL management (subcommand group — hq files share|unshare|acl)
|
|
103
104
|
registerFilesCommand(program);
|
|
104
105
|
|
|
106
|
+
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
107
|
+
registerMembersCommand(program);
|
|
108
|
+
|
|
105
109
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
106
110
|
registerOnboardCommand(program);
|
|
107
111
|
|