Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/admin-portal/admin-portal.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fetch from 'jest-fetch-mock';
import { fetchBody, fetchOnce } from '../common/utils/test-utils';
import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils';
import { WorkOS } from '../workos';
import generateLinkInvalid from './fixtures/generate-link-invalid.json';
import portalLinkResponse from './fixtures/portal-link-response.json';
Expand Down Expand Up @@ -193,6 +193,24 @@ describe('AdminPortal', () => {
});
});

describe('with an externalUserId', () => {
it('sends it as external_user_id', async () => {
fetchOnce(portalLinkResponse, { status: 201 });

await workos.adminPortal.generateLink({
intent: GenerateLinkIntent.SSO,
organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
externalUserId: 'user_12345',
});

expect(fetchBody()).toEqual({
intent: GenerateLinkIntent.SSO,
organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
external_user_id: 'user_12345',
});
});
});

describe('with an invalid organization', () => {
it('throws an error', async () => {
fetchOnce(generateLinkInvalid, {
Expand Down Expand Up @@ -241,4 +259,22 @@ describe('AdminPortal', () => {
});
});
});

describe('revokeLink', () => {
it('sends a revoke_link request', async () => {
fetchOnce();

const resp = await workos.adminPortal.revokeLink({
organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
externalUserId: 'user_12345',
});

expect(fetchURL()).toContain('/portal/revoke_link');
expect(fetchBody()).toEqual({
organization: 'org_01EHQMYV6MBK39QC5PZXHY59C3',
external_user_id: 'user_12345',
});
expect(resp).toBeUndefined();
});
});
});
34 changes: 33 additions & 1 deletion src/admin-portal/admin-portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export class AdminPortal {
* Generate a Portal Link
*
* Generate a Portal Link scoped to an Organization.
* @param payload - Object containing organization.
* @param payload - Object containing organization. Pass `externalUserId`
* for every admin you may later need to deprovision; only links generated
* with one can be revoked via `revokeLink`.
* @returns {Promise<{ link: string; }>}
* @throws {BadRequestException} 400
* @throws 403 response from the API.
Expand All @@ -24,6 +26,7 @@ export class AdminPortal {
successUrl,
intentOptions,
itContactEmails,
externalUserId,
}: {
intent?: GenerateLinkIntent;
organization: string;
Expand All @@ -36,6 +39,7 @@ export class AdminPortal {
};
};
itContactEmails?: string[];
externalUserId?: string;
}): Promise<{ link: string }> {
const { data } = await this.workos.post('/portal/generate_link', {
intent,
Expand All @@ -51,8 +55,36 @@ export class AdminPortal {
}
: undefined,
it_contact_emails: itContactEmails,
external_user_id: externalUserId,
});

return data;
}

/**
* Revoke Portal Links
*
* Revokes every Portal Link generated for the given organization with the
* given `externalUserId`, and immediately ends any Admin Portal sessions that
* were started from those links. Use this when an admin is deprovisioned from
* your application: unopened links stop working, and their already-open
* portal sessions are ended. Only links that were generated with an
* `externalUserId` can be revoked, so pass one to `generateLink` for every
* admin you may later need to deprovision.
* @param payload - Object containing organization and externalUserId.
* @throws {NotFoundException} 404
* @throws {UnprocessableEntityException} 422
*/
async revokeLink({
organization,
externalUserId,
}: {
organization: string;
externalUserId: string;
}): Promise<void> {
await this.workos.post('/portal/revoke_link', {
organization,
external_user_id: externalUserId,
});
}
}
3 changes: 2 additions & 1 deletion src/admin-portal/fixtures/generate-link.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
"provider_type": "GoogleSAML"
}
},
"it_contact_emails": ["it-contact@example.com"]
"it_contact_emails": ["it-contact@example.com"],
"external_user_id": "user_12345"
}
3 changes: 3 additions & 0 deletions src/admin-portal/interfaces/generate-link.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface GenerateLink {
intentOptions?: IntentOptions;
/** The email addresses of the IT contacts to grant access to the Admin Portal for the given organization. Accepts up to 20 emails. */
itContactEmails?: string[];
/** The ID of the user in your application the link is generated for. The link and any Admin Portal sessions started from it can later be revoked by this ID, for example when that user is deprovisioned from your application. */
externalUserId?: string;
}

export interface GenerateLinkResponse {
Expand All @@ -38,4 +40,5 @@ export interface GenerateLinkResponse {
intent?: GenerateLinkIntent;
intent_options?: IntentOptionsResponse;
it_contact_emails?: string[];
external_user_id?: string;
}
2 changes: 2 additions & 0 deletions src/admin-portal/serializers/generate-link.serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const deserializeGenerateLink = (
? deserializeIntentOptions(response.intent_options)
: undefined,
itContactEmails: response.it_contact_emails,
externalUserId: response.external_user_id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New serializer mapping lacks fixture coverage

The GenerateLink round-trip fixture omits external_user_id, so its test never checks whether the new field survives serialization.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

});

export const serializeGenerateLink = (
Expand All @@ -35,4 +36,5 @@ export const serializeGenerateLink = (
? serializeIntentOptions(model.intentOptions)
: undefined,
it_contact_emails: model.itContactEmails,
external_user_id: model.externalUserId,
});
Loading