From c55339540193a6e10d77746d34f86c94d5146231 Mon Sep 17 00:00:00 2001 From: Brandon Date: Mon, 4 May 2026 16:51:20 -0400 Subject: [PATCH 1/8] improve type safter for invite link, add allowed domains field, wire up local state --- .../by-link-tab.component.html | 44 +++ .../by-link-tab.component.ts | 123 +++++++- .../invite-members-dialog.component.html | 288 +++++++++--------- .../invite-members-dialog.component.ts | 1 + apps/web/src/locales/en/messages.json | 15 + .../models/response/organization.response.ts | 2 +- libs/common/src/enums/feature-flag.enum.ts | 2 +- libs/common/src/services/api.service.ts | 13 +- .../organization-invite-link-api.service.ts | 7 + .../organization-invite-link.service.ts | 36 ++- .../src/models/requests/index.ts | 1 + ...organization-invite-link-create.request.ts | 20 +- ...organization-invite-link-update.request.ts | 11 + .../organization-invite-link.response.ts | 26 ++ ...lt-organization-invite-link-api.service.ts | 15 + ...efault-organization-invite-link.service.ts | 131 ++++---- .../state/organization-invite-link-state.ts | 10 +- 17 files changed, 501 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html create mode 100644 libs/organization-invite-link/src/models/requests/organization-invite-link-update.request.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html new file mode 100644 index 000000000000..83e70a9ad18c --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html @@ -0,0 +1,44 @@ +

+ {{ "inviteByLinkDesc" | i18n }} + + {{ "learnMore" | i18n }} + +

+ +
+ + {{ "allowedDomains" | i18n }} + ({{ "required" | i18n }}) + + {{ "allowedDomainsHint" | i18n }} + + + @if (isDirty()) { + + } +
+ +@if (!inviteLink()) { + + + {{ "enterAllowedDomainsToGenerateInviteLink" | i18n }} + + +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts index c871437d9eb4..aed1cc056d66 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts @@ -1,9 +1,126 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + output, + Signal, + signal, +} from "@angular/core"; +import { takeUntilDestroyed, toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; +import { combineLatest, firstValueFrom, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + AsyncActionsModule, + ButtonModule, + CalloutModule, + FormFieldModule, + LinkComponent, + ToastService, +} from "@bitwarden/components"; +import { + OrganizationInviteLink, + OrganizationInviteLinkService, +} from "@bitwarden/organization-invite-link"; +import { I18nPipe } from "@bitwarden/ui-common"; @Component({ standalone: true, selector: "app-by-link-tab", - template: ``, + templateUrl: "by-link-tab.component.html", changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AsyncActionsModule, + ButtonModule, + CalloutModule, + CommonModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + LinkComponent, + ], }) -export class ByLinkTabComponent {} +export class ByLinkTabComponent { + readonly organizationId = input.required({ + transform: (value: string) => value as OrganizationId, + }); + readonly isDirtyChange = output(); + + private readonly accountService = inject(AccountService); + private readonly inviteLinkService = inject(OrganizationInviteLinkService); + private readonly toastService = inject(ToastService); + private readonly i18nService = inject(I18nService); + private readonly fb = inject(FormBuilder); + + protected readonly isDirty = signal(false); + protected readonly inviteLink: Signal = toSignal( + combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + toObservable(this.organizationId), + ]).pipe( + switchMap(([userId, organizationId]) => + this.inviteLinkService.inviteLink$(userId, organizationId), + ), + ), + ); + + protected readonly form = this.fb.group({ + domains: ["", Validators.required], + }); + + constructor() { + this.form.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { + const dirty = this.form.dirty; + this.isDirty.set(dirty); + this.isDirtyChange.emit(dirty); + }); + + effect(() => { + const inviteLink = this.inviteLink(); + if (inviteLink && !this.form.dirty) { + this.form.controls.domains.setValue(inviteLink.allowedDomains.join(", ")); + this.form.markAsPristine(); + } + }); + } + + readonly save = async () => { + this.form.markAllAsTouched(); + if (this.form.invalid) { + return; + } + + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const rawDomains = this.form.value.domains; + if (rawDomains == null) { + throw new Error("Must provide at least one valid domain."); + } + + const domains = rawDomains + .split(",") + .map((domain) => domain.trim()) + .filter((domain) => domain.length > 0); + + if (this.inviteLink()) { + await this.inviteLinkService.updateInviteLink(userId, this.organizationId(), domains); + } else { + await this.inviteLinkService.createInviteLink(userId, this.organizationId(), domains); + } + + this.form.markAsPristine(); + this.isDirty.set(false); + this.isDirtyChange.emit(false); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("domainsEdited"), + }); + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.html index 8bf6c9bf7953..2390bd76527a 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.html @@ -14,7 +14,10 @@ > - + } @else { @@ -29,7 +32,10 @@ bitButton bitFormButton buttonType="primary" - [disabled]="selectedTabIndex() === 0 && formGroup.controls.emails.invalid" + [disabled]=" + (selectedTabIndex() === 0 && formGroup.controls.emails.invalid) || + (selectedTabIndex() === 1 && byLinkIsDirty()) + " > {{ selectedTabIndex() === 0 ? ("invite" | i18n) : ("copyLink" | i18n) }} @@ -37,147 +43,147 @@ {{ "cancel" | i18n }} - - - -

{{ "inviteUserDesc" | i18n }}

- @if ({ seats: remainingSeats$ | async }; as remaining) { - - {{ "email" | i18n }} - - @if (remaining.seats > 1) { - {{ "inviteMultipleEmailDesc" | i18n: remaining.seats }} - } - @if (remaining.seats === 1) { - {{ "inviteSingleEmailDesc" | i18n }} + +

{{ "inviteUserDesc" | i18n }}

+ @if ({ seats: remainingSeats$ | async }; as remaining) { + + {{ "email" | i18n }} + + @if (remaining.seats > 1) { + {{ "inviteMultipleEmailDesc" | i18n: remaining.seats }} + } + @if (remaining.seats === 1) { + {{ "inviteSingleEmailDesc" | i18n }} + } + @if (remaining.seats === 0) { + {{ "inviteZeroEmailDesc" | i18n }} + } + } - @if (remaining.seats === 0) { - {{ "inviteZeroEmailDesc" | i18n }} - } -
- } - - - {{ "memberRole" | i18n }} - - - - - - - - - + + + {{ "memberRole" | i18n }} + + + + + + + + + - @if (customUserTypeSelected()) { -
-
- - - {{ "accessEventLogs" | i18n }} - - - - {{ "accessImportExport" | i18n }} - + @if (customUserTypeSelected()) { +
+
+ + + {{ "accessEventLogs" | i18n }} + + + + {{ "accessImportExport" | i18n }} + + + + {{ "accessReports" | i18n }} + +
+
+
+ + + {{ "manageGroups" | i18n }} + + + + {{ "manageSso" | i18n }} + + + + {{ "managePolicies" | i18n }} + + + + {{ "manageUsers" | i18n }} + + + + {{ "manageAccountRecovery" | i18n }} + +
+
+
+ } + @if (organization.useSecretsManager) { - - {{ "accessReports" | i18n }} +
+ + + {{ "grantSecretsManager" | i18n }} + + + + +
-
-
-
- - - {{ "manageGroups" | i18n }} - - - - {{ "manageSso" | i18n }} - - - - {{ "managePolicies" | i18n }} - - - - {{ "manageUsers" | i18n }} - - - - {{ "manageAccountRecovery" | i18n }} - -
-
-
- } - @if (organization.useSecretsManager) { - -
- - - {{ "grantSecretsManager" | i18n }} - - - - -
-
- } - - - @if (organization.useGroups) { - - } - - -
+ } + + + @if (organization.useGroups) { + + } + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts index df0639880120..8f2f26d99ed4 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts @@ -112,6 +112,7 @@ export class InviteMembersDialogComponent { protected readonly isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone; protected readonly selectedTabIndex = signal(0); protected readonly moreSettingsOpen = signal(false); + protected readonly byLinkIsDirty = signal(false); protected readonly formGroup = this.formBuilder.group({ emails: [""], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 598196a72d36..359f6c07efe4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5435,6 +5435,21 @@ "byLink": { "message": "By link" }, + "allowedDomains": { + "message": "Allowed domains" + }, + "allowedDomainsHint": { + "message": "If entering multiple domains, separate with commas" + }, + "enterAllowedDomainsToGenerateInviteLink": { + "message": "Enter Allowed domains to generate an invite link" + }, + "domainsEdited": { + "message": "Domains edited" + }, + "inviteByLinkDesc": { + "message": "Invite members to your organization with a link. If no role has been specified yet, they’ll be added as a default user." + }, "byContinuingYouAgreeToThe": { "message": "By continuing, you agree to the" }, diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index c30fbcadde0b..b760112f231d 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -89,6 +89,6 @@ export class OrganizationResponse extends BaseResponse { this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights"); this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false; this.useMyItems = this.getResponseProperty("UseMyItems") ?? false; - this.useInviteLinks = this.getResponseProperty("UseInviteLinks") ?? false; + this.useInviteLinks = this.getResponseProperty("UseInviteLinks") ?? true; } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 71f6b6b2842b..4bff1466f8f7 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -121,7 +121,7 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AdminResetTwoFactor]: FALSE, [FeatureFlag.BulkAutoConfirmOnLogin]: FALSE, - [FeatureFlag.GenerateInviteLink]: FALSE, + [FeatureFlag.GenerateInviteLink]: true, [FeatureFlag.PM35153CollectionSdkDecryption]: FALSE, [FeatureFlag.PolicyDrawers]: FALSE, diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 120e503cc79d..89ed4f313dae 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1696,21 +1696,20 @@ export class ApiService implements ApiServiceAbstraction { const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1; const responseIsBlob = responseType != null && responseType.indexOf("application/octet-stream") !== -1; - if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) { + const responseIsSuccess = + response.status === HttpStatusCode.Ok || response.status === HttpStatusCode.Created; + if (hasResponse && responseIsSuccess && responseIsJson) { const responseJson = await response.json(); return responseJson; - } else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) { + } else if (hasResponse && responseIsSuccess && responseIsCsv) { return await response.text(); - } else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsBlob) { + } else if (hasResponse && responseIsSuccess && responseIsBlob) { const disposition = response.headers.get("Content-Disposition") ?? ""; const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); const fileName = match ? match[1].replace(/['"]/g, "") : "download"; const blob = await response.blob(); return { blob, fileName }; - } else if ( - response.status !== HttpStatusCode.Ok && - response.status !== HttpStatusCode.NoContent - ) { + } else if (!responseIsSuccess && response.status !== HttpStatusCode.NoContent) { const error = await this.handleApiRequestError(response, userIdMakingRequest != null); return Promise.reject(error); } diff --git a/libs/organization-invite-link/src/abstractions/organization-invite-link-api.service.ts b/libs/organization-invite-link/src/abstractions/organization-invite-link-api.service.ts index d703b458ad87..2a5b39ddfd8c 100644 --- a/libs/organization-invite-link/src/abstractions/organization-invite-link-api.service.ts +++ b/libs/organization-invite-link/src/abstractions/organization-invite-link-api.service.ts @@ -1,4 +1,5 @@ import { OrganizationInviteLinkCreateRequest } from "../models/requests/organization-invite-link-create.request"; +import { OrganizationInviteLinkUpdateRequest } from "../models/requests/organization-invite-link-update.request"; import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; export abstract class OrganizationInviteLinkApiService { @@ -8,6 +9,12 @@ export abstract class OrganizationInviteLinkApiService { request: OrganizationInviteLinkCreateRequest, ): Promise; + /** Update the allowed domains for the given organization's invite link */ + abstract update( + organizationId: string, + request: OrganizationInviteLinkUpdateRequest, + ): Promise; + /** Retrieve the current invite link for the given organization */ abstract get(organizationId: string): Promise; diff --git a/libs/organization-invite-link/src/abstractions/organization-invite-link.service.ts b/libs/organization-invite-link/src/abstractions/organization-invite-link.service.ts index 4eef49465126..9e8bf37ff27a 100644 --- a/libs/organization-invite-link/src/abstractions/organization-invite-link.service.ts +++ b/libs/organization-invite-link/src/abstractions/organization-invite-link.service.ts @@ -2,40 +2,46 @@ import { Observable } from "rxjs"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; +import { OrganizationInviteLink } from "../models/responses/organization-invite-link.response"; export abstract class OrganizationInviteLinkService { /** Observable stream of the cached invite link for the given user */ - abstract inviteLink$(userId: UserId): Observable; + abstract inviteLink$( + userId: UserId, + orgId: OrganizationId, + ): Observable; /** * Create a new invite link for the organization. - * Emits the shareable URL once, then completes. */ abstract createInviteLink( userId: UserId, orgId: OrganizationId, domains: string[], - ): Promise; + ): Promise; /** - * Refresh the invite link using cached allowed domains. - * Emits the shareable URL once, then completes. + * Update the allowed domains on an existing invite link. */ - abstract refreshInviteLink(userId: UserId, orgId: OrganizationId): Promise; + abstract updateInviteLink( + userId: UserId, + orgId: OrganizationId, + domains: string[], + ): Promise; /** - * Reconstruct the shareable URL from the server-stored invite link. - * Emits the URL (or undefined when none exists), then completes. + * Updates the Organization invite link without modifying allowed domains (generates a new link) */ - abstract reconstructUrl(userId: UserId, orgId: OrganizationId): Promise; + abstract refreshInviteLink(userId: UserId, orgId: OrganizationId): Promise; - /** Persist an invite link response to local state */ - abstract upsert(userId: UserId, data: OrganizationInviteLinkResponseModel): Promise; + /** + * Reconstruct and returns the shareable URL from OrganizationInviteLink in local state as a string + */ + abstract reconstructUrl(userId: UserId, orgId: OrganizationId): Promise; + + /** Persist an invite link to local state */ + abstract upsert(userId: UserId, data: OrganizationInviteLink): Promise; /** Delete (revoke) the invite link via the API and clear local cached state */ abstract delete(userId: UserId, orgId: OrganizationId): Promise; - - /** Clear local cached invite link state for the user without calling the API */ - abstract clear(userId: UserId): Promise; } diff --git a/libs/organization-invite-link/src/models/requests/index.ts b/libs/organization-invite-link/src/models/requests/index.ts index 3e984901fd7d..65cf7110e623 100644 --- a/libs/organization-invite-link/src/models/requests/index.ts +++ b/libs/organization-invite-link/src/models/requests/index.ts @@ -1 +1,2 @@ export * from "./organization-invite-link-create.request"; +export * from "./organization-invite-link-update.request"; diff --git a/libs/organization-invite-link/src/models/requests/organization-invite-link-create.request.ts b/libs/organization-invite-link/src/models/requests/organization-invite-link-create.request.ts index 3ac20f3af0bb..032c434df45c 100644 --- a/libs/organization-invite-link/src/models/requests/organization-invite-link-create.request.ts +++ b/libs/organization-invite-link/src/models/requests/organization-invite-link-create.request.ts @@ -5,20 +5,20 @@ export class OrganizationInviteLinkCreateRequest { encryptedInviteKey: string; encryptedOrgKey: string | null; - constructor(c: { - allowedDomains: string[]; - encryptedInviteKey: EncString; - encryptedOrgKey?: EncString | null; - }) { - if (!c.allowedDomains || c.allowedDomains.length === 0) { + constructor( + allowedDomains: string[], + encryptedInviteKey: EncString, + encryptedOrgKey?: EncString | null, + ) { + if (!allowedDomains || allowedDomains.length === 0) { throw new Error("At least one allowed domain is required."); } - if (!c.encryptedInviteKey?.encryptedString) { + if (!encryptedInviteKey?.encryptedString) { throw new Error("EncryptedInviteKey is required."); } - this.allowedDomains = c.allowedDomains; - this.encryptedInviteKey = c.encryptedInviteKey.encryptedString; - this.encryptedOrgKey = c.encryptedOrgKey?.encryptedString ?? null; + this.allowedDomains = allowedDomains; + this.encryptedInviteKey = encryptedInviteKey.encryptedString; + this.encryptedOrgKey = encryptedOrgKey?.encryptedString ?? null; } } diff --git a/libs/organization-invite-link/src/models/requests/organization-invite-link-update.request.ts b/libs/organization-invite-link/src/models/requests/organization-invite-link-update.request.ts new file mode 100644 index 000000000000..b7aac149b585 --- /dev/null +++ b/libs/organization-invite-link/src/models/requests/organization-invite-link-update.request.ts @@ -0,0 +1,11 @@ +export class OrganizationInviteLinkUpdateRequest { + allowedDomains: string[]; + + constructor(allowedDomains: string[]) { + if (!allowedDomains || allowedDomains.length === 0) { + throw new Error("At least one allowed domain is required."); + } + + this.allowedDomains = allowedDomains; + } +} diff --git a/libs/organization-invite-link/src/models/responses/organization-invite-link.response.ts b/libs/organization-invite-link/src/models/responses/organization-invite-link.response.ts index 4d03ee697797..6dc520304716 100644 --- a/libs/organization-invite-link/src/models/responses/organization-invite-link.response.ts +++ b/libs/organization-invite-link/src/models/responses/organization-invite-link.response.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { BaseResponse } from "@bitwarden/common/models/response/base.response"; export class OrganizationInviteLinkResponseModel extends BaseResponse { @@ -20,3 +22,27 @@ export class OrganizationInviteLinkResponseModel extends BaseResponse { this.creationDate = this.getResponseProperty("CreationDate"); } } + +export class OrganizationInviteLink { + id: string; + code: string; + organizationId: string; + allowedDomains: string[]; + encryptedInviteKey: string; + encryptedOrgKey: string | undefined; + creationDate: string; + + constructor(response: OrganizationInviteLinkResponseModel) { + this.id = response.id; + this.code = response.code; + this.organizationId = response.organizationId; + this.allowedDomains = response.allowedDomains; + this.encryptedInviteKey = response.encryptedInviteKey; + this.encryptedOrgKey = response.encryptedOrgKey; + this.creationDate = response.creationDate; + } + + static fromJSON(obj: Jsonify): OrganizationInviteLink { + return Object.assign(new OrganizationInviteLink(obj as any), obj); + } +} diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link-api.service.ts b/libs/organization-invite-link/src/services/default-organization-invite-link-api.service.ts index f3f5ca17e2ea..e03fa752a5fb 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link-api.service.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link-api.service.ts @@ -2,6 +2,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationInviteLinkApiService } from "../abstractions/organization-invite-link-api.service"; import { OrganizationInviteLinkCreateRequest } from "../models/requests/organization-invite-link-create.request"; +import { OrganizationInviteLinkUpdateRequest } from "../models/requests/organization-invite-link-update.request"; import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; export class DefaultOrganizationInviteLinkApiService implements OrganizationInviteLinkApiService { @@ -21,6 +22,20 @@ export class DefaultOrganizationInviteLinkApiService implements OrganizationInvi return new OrganizationInviteLinkResponseModel(r); } + async update( + organizationId: string, + request: OrganizationInviteLinkUpdateRequest, + ): Promise { + const r = await this.apiService.send( + "PUT", + `/organizations/${organizationId}/invite-link`, + request, + true, + true, + ); + return new OrganizationInviteLinkResponseModel(r); + } + async get(organizationId: string): Promise { const r = await this.apiService.send( "GET", diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts index 766f11101142..99b00510ed8c 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable } from "rxjs"; +import { firstValueFrom, map, Observable, of, switchMap } from "rxjs"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -11,7 +11,8 @@ import { StateProvider } from "@bitwarden/state"; import { OrganizationInviteLinkApiService } from "../abstractions/organization-invite-link-api.service"; import { OrganizationInviteLinkService } from "../abstractions/organization-invite-link.service"; import { OrganizationInviteLinkCreateRequest } from "../models/requests/organization-invite-link-create.request"; -import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; +import { OrganizationInviteLinkUpdateRequest } from "../models/requests/organization-invite-link-update.request"; +import { OrganizationInviteLink } from "../models/responses/organization-invite-link.response"; import { ORGANIZATION_INVITE_LINK_KEY } from "../state/organization-invite-link-state"; export class DefaultOrganizationInviteLinkService implements OrganizationInviteLinkService { @@ -23,93 +24,99 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL private readonly stateProvider: StateProvider, ) {} - inviteLink$(userId: UserId): Observable { - return this.stateProvider - .getUser(userId, ORGANIZATION_INVITE_LINK_KEY) - .state$.pipe(map((state) => state ?? undefined)); - } - - async createInviteLink( + inviteLink$( userId: UserId, orgId: OrganizationId, - domains: string[], - ): Promise { - const rawInviteKey = await this.generateCryptoBundle(); - - const orgKey = await firstValueFrom( - this.keyService.orgKeys$(userId).pipe( - map((orgKeys) => { - const orgKey = orgKeys?.[orgId as OrganizationId] ?? undefined; - if (orgKey == null) { - throw new Error(`Organization key not found for org ${orgId}`); - } - - return orgKey; - }), - ), + ): Observable { + return this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).state$.pipe( + switchMap((state) => { + // If local state is empty, try to GET and upsert result. + if (state == null) { + return this.getInviteLink(userId, orgId); + } + return of(state ?? undefined); + }), ); + } + async createInviteLink(userId: UserId, orgId: OrganizationId, domains: string[]): Promise { + const rawInviteKey = await this.generateCryptoBundle(); + const orgKey = await firstValueFrom(this.getOrgKey(userId, orgId)); const encryptedInviteKey = await this.encryptService.wrapSymmetricKey(rawInviteKey, orgKey); + const request = new OrganizationInviteLinkCreateRequest(domains, encryptedInviteKey); + const response = await this.apiService.create(orgId, request); + const inviteLink = new OrganizationInviteLink(response); - const request = new OrganizationInviteLinkCreateRequest({ - allowedDomains: domains, - encryptedInviteKey, - }); + await this.upsert(userId, inviteLink); + } - const response = await this.apiService.create(orgId, request); - await this.upsert(userId, response); + async updateInviteLink(userId: UserId, orgId: OrganizationId, domains: string[]): Promise { + const request = new OrganizationInviteLinkUpdateRequest(domains); + const response = await this.apiService.update(orgId, request); + const inviteLink = new OrganizationInviteLink(response); - return this.buildInviteUrl(response.code, rawInviteKey.keyB64); + await this.upsert(userId, inviteLink); } - async refreshInviteLink(userId: UserId, orgId: OrganizationId): Promise { - const cached = await firstValueFrom(this.inviteLink$(userId)); - const domains = cached?.allowedDomains ?? []; - return this.createInviteLink(userId, orgId, domains); + async refreshInviteLink(userId: UserId, orgId: OrganizationId): Promise { + const inviteLink = await firstValueFrom(this.inviteLink$(userId, orgId)); + const domains = inviteLink?.allowedDomains ?? []; + await this.updateInviteLink(userId, orgId, domains); } - async reconstructUrl(userId: UserId, orgId: OrganizationId): Promise { - const response = await this.apiService.get(orgId); - if (response == null) { - return; - } - - await this.upsert(userId, response); - - const orgKey = await firstValueFrom( - this.keyService.orgKeys$(userId).pipe( - map((orgKeys) => { - const orgKey = orgKeys?.[orgId] ?? undefined; - if (orgKey == null) { - throw new Error(`Organization key not found for org ${orgId}`); + async reconstructUrl(userId: UserId, orgId: OrganizationId): Promise { + const inviteLink = await firstValueFrom( + this.inviteLink$(userId, orgId).pipe( + map((inviteLink) => { + if (inviteLink == null) { + throw new Error("Organization does not have an invite link to reconstruct"); } - - return orgKey; + return inviteLink; }), ), ); - - const encKey = new EncString(response.encryptedInviteKey); + const orgKey = await firstValueFrom(this.getOrgKey(userId, orgId)); + const encKey = new EncString(inviteLink.encryptedInviteKey); const rawInviteKey = await this.encryptService.unwrapSymmetricKey(encKey, orgKey); - return this.buildInviteUrl(response.code, rawInviteKey.keyB64); - } - - private buildInviteUrl(code: string, keyB64: string): string { - return `/#/join/${code}?key=${keyB64}`; + return this.buildInviteUrl(inviteLink.code, rawInviteKey.keyB64); } - async upsert(userId: UserId, data: OrganizationInviteLinkResponseModel): Promise { + async upsert(userId: UserId, data: OrganizationInviteLink): Promise { await this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).update(() => data); } async delete(userId: UserId, orgId: OrganizationId): Promise { await this.apiService.delete(orgId); - await this.clear(userId); + await this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).update(() => undefined); } - async clear(userId: UserId): Promise { - await this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).update(() => undefined); + private buildInviteUrl(code: string, keyB64: string): string { + return `/#/join/${code}?key=${keyB64}`; + } + + private async getInviteLink( + userId: UserId, + orgId: OrganizationId, + ): Promise { + const response = await this.apiService.get(orgId); + const inviteLink = new OrganizationInviteLink(response); + + await this.upsert(userId, inviteLink); + return inviteLink; + } + + private getOrgKey(userId: UserId, orgId: OrganizationId) { + return this.keyService.orgKeys$(userId).pipe( + map((orgKeys) => { + const orgKey = orgKeys?.[orgId] ?? undefined; + if (orgKey == null) { + throw new Error(`Organization key not found for org ${orgId}`); + } + + return orgKey; + }), + ); } /** diff --git a/libs/organization-invite-link/src/state/organization-invite-link-state.ts b/libs/organization-invite-link/src/state/organization-invite-link-state.ts index 9376c48479c6..3f150eb4ca7a 100644 --- a/libs/organization-invite-link/src/state/organization-invite-link-state.ts +++ b/libs/organization-invite-link/src/state/organization-invite-link-state.ts @@ -1,11 +1,13 @@ +import { Jsonify } from "type-fest"; + import { ORGANIZATION_INVITE_LINK_DISK, UserKeyDefinition } from "@bitwarden/state"; -import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; +import { OrganizationInviteLink } from "../models/responses/organization-invite-link.response"; export const ORGANIZATION_INVITE_LINK_KEY = new UserKeyDefinition< - OrganizationInviteLinkResponseModel | undefined + OrganizationInviteLink | undefined >(ORGANIZATION_INVITE_LINK_DISK, "inviteLink", { - deserializer: (obj) => - obj == null ? undefined : Object.assign(new OrganizationInviteLinkResponseModel(obj), obj), + deserializer: (obj: Jsonify) => + obj == null ? undefined : OrganizationInviteLink.fromJSON(obj), clearOn: ["logout"], }); From 705dcb7d19c069c86cc1ab0f2ad549388fdbb501 Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 5 May 2026 12:25:06 -0400 Subject: [PATCH 2/8] clean up --- .../src/admin-console/models/response/organization.response.ts | 2 +- libs/common/src/enums/feature-flag.enum.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index b760112f231d..c30fbcadde0b 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -89,6 +89,6 @@ export class OrganizationResponse extends BaseResponse { this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights"); this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false; this.useMyItems = this.getResponseProperty("UseMyItems") ?? false; - this.useInviteLinks = this.getResponseProperty("UseInviteLinks") ?? true; + this.useInviteLinks = this.getResponseProperty("UseInviteLinks") ?? false; } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 4bff1466f8f7..71f6b6b2842b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -121,7 +121,7 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AdminResetTwoFactor]: FALSE, [FeatureFlag.BulkAutoConfirmOnLogin]: FALSE, - [FeatureFlag.GenerateInviteLink]: true, + [FeatureFlag.GenerateInviteLink]: FALSE, [FeatureFlag.PM35153CollectionSdkDecryption]: FALSE, [FeatureFlag.PolicyDrawers]: FALSE, From a307152ce59280e3a7963009c97b6a7be19c29af Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 5 May 2026 15:26:16 -0400 Subject: [PATCH 3/8] fix reactivity --- .../by-link-tab.component.html | 4 +- .../by-link-tab.component.ts | 49 ++++++++----------- .../invite-members-dialog.component.html | 10 +--- ...efault-organization-invite-link.service.ts | 24 ++++++--- 4 files changed, 41 insertions(+), 46 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html index 2a9c4ebd72fd..57ba1c2299f2 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html @@ -21,7 +21,7 @@ {{ "allowedDomainsHint" | i18n }} - @if (isDirty()) { + @if (form.dirty) { diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts index 99b00510ed8c..fc36a5633823 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { catchError, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -12,7 +12,10 @@ import { OrganizationInviteLinkApiService } from "../abstractions/organization-i import { OrganizationInviteLinkService } from "../abstractions/organization-invite-link.service"; import { OrganizationInviteLinkCreateRequest } from "../models/requests/organization-invite-link-create.request"; import { OrganizationInviteLinkUpdateRequest } from "../models/requests/organization-invite-link-update.request"; -import { OrganizationInviteLink } from "../models/responses/organization-invite-link.response"; +import { + OrganizationInviteLink, + OrganizationInviteLinkResponseModel, +} from "../models/responses/organization-invite-link.response"; import { ORGANIZATION_INVITE_LINK_KEY } from "../state/organization-invite-link-state"; export class DefaultOrganizationInviteLinkService implements OrganizationInviteLinkService { @@ -30,12 +33,12 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL ): Observable { return this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).state$.pipe( switchMap((state) => { - // If local state is empty, try to GET and upsert result. if (state == null) { return this.getInviteLink(userId, orgId); } - return of(state ?? undefined); + return of(state); }), + catchError(() => of(undefined)), ); } @@ -88,7 +91,6 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL async delete(userId: UserId, orgId: OrganizationId): Promise { await this.apiService.delete(orgId); - await this.stateProvider.getUser(userId, ORGANIZATION_INVITE_LINK_KEY).update(() => undefined); } private buildInviteUrl(code: string, keyB64: string): string { @@ -99,9 +101,17 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL userId: UserId, orgId: OrganizationId, ): Promise { - const response = await this.apiService.get(orgId); - const inviteLink = new OrganizationInviteLink(response); + let response: OrganizationInviteLinkResponseModel; + try { + response = await this.apiService.get(orgId); + } catch (e: any) { + if (e.status === 404) { + return undefined; + } + throw e; + } + const inviteLink = new OrganizationInviteLink(response); await this.upsert(userId, inviteLink); return inviteLink; } From 8cb0e90dc6dbc41d600ee006d2a1b358a913c2c7 Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 5 May 2026 15:28:59 -0400 Subject: [PATCH 4/8] clean up --- .../invite-members-dialog/invite-members-dialog.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts index 38612c800a56..9b55d4e581f3 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/invite-members-dialog.component.ts @@ -113,7 +113,6 @@ export class InviteMembersDialogComponent { protected readonly isOnSecretsManagerStandalone = this.params.isOnSecretsManagerStandalone; protected readonly selectedTabIndex = signal(0); protected readonly moreSettingsOpen = signal(false); - protected readonly byLinkIsDirty = signal(false); protected readonly formGroup = this.formBuilder.group({ emails: [""], From dfc029ca6ca277f4db22d3be15d93941c2c76b47 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 6 May 2026 13:18:31 -0400 Subject: [PATCH 5/8] fix 404 handling, remove redundant signal --- .../by-link-tab.component.html | 3 ++- .../by-link-tab.component.ts | 22 +++---------------- ...efault-organization-invite-link.service.ts | 8 +++---- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html index 57ba1c2299f2..110e26d507d2 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.html @@ -35,7 +35,8 @@ } -@if (showCallout()) { +@let inviteLink = inviteLink$ | async; +@if (inviteLink == null) { {{ "enterAllowedDomainsToGenerateInviteLink" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts index abe2d37c43a0..27cf0bf764bd 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts @@ -1,16 +1,8 @@ import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - inject, - input, - OnInit, - signal, - WritableSignal, -} from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -46,7 +38,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; LinkComponent, ], }) -export class ByLinkTabComponent implements OnInit { +export class ByLinkTabComponent { readonly organizationId = input.required({ transform: (value: string) => value as OrganizationId, }); @@ -62,8 +54,6 @@ export class ByLinkTabComponent implements OnInit { toObservable(this.organizationId), ]).pipe(switchMap(([userId, orgId]) => this.inviteLinkService.inviteLink$(userId, orgId))); - protected readonly showCallout: WritableSignal = signal(false); - protected readonly form = this.fb.group({ domains: ["", Validators.required], }); @@ -77,10 +67,6 @@ export class ByLinkTabComponent implements OnInit { }); } - async ngOnInit() { - this.showCallout.set(await firstValueFrom(this.inviteLink$.pipe(map((link) => link == null)))); - } - readonly save = async () => { this.form.markAllAsTouched(); if (this.form.invalid) { @@ -107,8 +93,6 @@ export class ByLinkTabComponent implements OnInit { this.form.markAsPristine(); - this.showCallout.set(false); - this.toastService.showToast({ variant: "success", message: this.i18nService.t("domainsEdited"), diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts index fc36a5633823..ab12748cc37b 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link.service.ts @@ -1,8 +1,9 @@ -import { catchError, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { firstValueFrom, map, Observable, of, switchMap } from "rxjs"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -38,7 +39,6 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL } return of(state); }), - catchError(() => of(undefined)), ); } @@ -104,8 +104,8 @@ export class DefaultOrganizationInviteLinkService implements OrganizationInviteL let response: OrganizationInviteLinkResponseModel; try { response = await this.apiService.get(orgId); - } catch (e: any) { - if (e.status === 404) { + } catch (e) { + if (e instanceof ErrorResponse && e.statusCode === 404) { return undefined; } throw e; From 90a5b13e75adb1f6e2d679bbfd607417cceb2973 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 6 May 2026 13:20:14 -0400 Subject: [PATCH 6/8] add shareReplay --- .../invite-members-dialog/by-link-tab.component.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts index 27cf0bf764bd..78dfc7f25dfe 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/invite-members-dialog/by-link-tab.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { combineLatest, firstValueFrom, Observable, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, Observable, shareReplay, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -52,7 +52,10 @@ export class ByLinkTabComponent { protected readonly inviteLink$: Observable = combineLatest([ this.accountService.activeAccount$.pipe(getUserId), toObservable(this.organizationId), - ]).pipe(switchMap(([userId, orgId]) => this.inviteLinkService.inviteLink$(userId, orgId))); + ]).pipe( + switchMap(([userId, orgId]) => this.inviteLinkService.inviteLink$(userId, orgId)), + shareReplay({ bufferSize: 1, refCount: true }), + ); protected readonly form = this.fb.group({ domains: ["", Validators.required], From e9c0a798177bcbdd66e7a22a0134f709168f980d Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 6 May 2026 13:47:02 -0400 Subject: [PATCH 7/8] update tests --- ...t-organization-invite-link.service.spec.ts | 183 +++++++++++------- 1 file changed, 112 insertions(+), 71 deletions(-) diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts b/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts index 8a689b1c48df..f3b9ae116743 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; @@ -11,7 +12,10 @@ import { KeyService } from "@bitwarden/key-management"; import { FakeActiveUserAccessor, FakeStateProvider } from "@bitwarden/state-test-utils"; import { OrganizationInviteLinkApiService } from "../abstractions/organization-invite-link-api.service"; -import { OrganizationInviteLinkResponseModel } from "../models/responses/organization-invite-link.response"; +import { + OrganizationInviteLink, + OrganizationInviteLinkResponseModel, +} from "../models/responses/organization-invite-link.response"; import { ORGANIZATION_INVITE_LINK_KEY } from "../state/organization-invite-link-state"; import { DefaultOrganizationInviteLinkService } from "./default-organization-invite-link.service"; @@ -25,17 +29,25 @@ function makeKey(keyB64 = "dGVzdGtleWJ5dGVzZm9ydGVzdGluZw=="): SymmetricCryptoKe return key; } -function makeResponse( +function makeResponseModel( overrides: Partial = {}, ): OrganizationInviteLinkResponseModel { const resp = mock(); + resp.id = "link-id"; resp.code = "abc123"; resp.allowedDomains = ["example.com"]; resp.encryptedInviteKey = "2.enc=|iv=|mac="; + resp.encryptedOrgKey = undefined; resp.organizationId = mockOrgId; + resp.creationDate = "2024-01-01T00:00:00Z"; return Object.assign(resp, overrides); } +function makeInviteLink(overrides: Partial = {}): OrganizationInviteLink { + const link = new OrganizationInviteLink(makeResponseModel()); + return Object.assign(link, overrides); +} + describe("DefaultOrganizationInviteLinkService", () => { let sut: DefaultOrganizationInviteLinkService; let keyService: MockProxy; @@ -63,78 +75,87 @@ describe("DefaultOrganizationInviteLinkService", () => { }); describe("inviteLink$", () => { - it("emits undefined initially", async () => { - const value = await firstValueFrom(sut.inviteLink$(mockUserId)); + it("fetches from API when cache is empty", async () => { + const response = makeResponseModel(); + apiService.get.mockResolvedValue(response); + + const value = await firstValueFrom(sut.inviteLink$(mockUserId, mockOrgId)); + + expect(apiService.get).toHaveBeenCalledWith(mockOrgId); + expect(value).toEqual(new OrganizationInviteLink(response)); + }); + + it("returns undefined when API returns 404", async () => { + const notFound = new ErrorResponse(null, 404); + apiService.get.mockRejectedValue(notFound); + + const value = await firstValueFrom(sut.inviteLink$(mockUserId, mockOrgId)); + expect(value).toBeUndefined(); }); - it("emits stored value after upsert", async () => { - const response = makeResponse(); - await sut.upsert(mockUserId, response); - const value = await firstValueFrom(sut.inviteLink$(mockUserId)); - expect(value).toEqual(response); + it("rethrows non-404 API errors", async () => { + const serverError = new ErrorResponse(null, 500); + apiService.get.mockRejectedValue(serverError); + + await expect(firstValueFrom(sut.inviteLink$(mockUserId, mockOrgId))).rejects.toThrow(); }); - it("emits undefined after clear", async () => { - await sut.upsert(mockUserId, makeResponse()); - await sut.clear(mockUserId); - const value = await firstValueFrom(sut.inviteLink$(mockUserId)); - expect(value).toBeUndefined(); + it("emits cached value without calling API again", async () => { + const inviteLink = makeInviteLink(); + await sut.upsert(mockUserId, inviteLink); + + const value = await firstValueFrom(sut.inviteLink$(mockUserId, mockOrgId)); + + expect(apiService.get).not.toHaveBeenCalled(); + expect(value).toEqual(inviteLink); }); }); describe("upsert", () => { - it("writes response to state", async () => { - const response = makeResponse(); - await sut.upsert(mockUserId, response); - const stored = await firstValueFrom( - stateProvider.getUser(mockUserId, ORGANIZATION_INVITE_LINK_KEY).state$, - ); - expect(stored).toEqual(response); - }); - }); + it("writes OrganizationInviteLink to state", async () => { + const inviteLink = makeInviteLink(); + await sut.upsert(mockUserId, inviteLink); - describe("clear", () => { - it("nulls local state without calling the API", async () => { - await sut.upsert(mockUserId, makeResponse()); - await sut.clear(mockUserId); const stored = await firstValueFrom( stateProvider.getUser(mockUserId, ORGANIZATION_INVITE_LINK_KEY).state$, ); - expect(stored).toBeFalsy(); - expect(apiService.delete).not.toHaveBeenCalled(); + expect(stored).toEqual(inviteLink); }); }); describe("delete", () => { - it("calls API delete then clears local state", async () => { + it("calls API delete without modifying local state", async () => { + const inviteLink = makeInviteLink(); + await sut.upsert(mockUserId, inviteLink); apiService.delete.mockResolvedValue(); - await sut.upsert(mockUserId, makeResponse()); await sut.delete(mockUserId, mockOrgId); expect(apiService.delete).toHaveBeenCalledWith(mockOrgId); + + // State should remain untouched after delete const stored = await firstValueFrom( stateProvider.getUser(mockUserId, ORGANIZATION_INVITE_LINK_KEY).state$, ); - expect(stored).toBeFalsy(); + expect(stored).toEqual(inviteLink); }); }); describe("createInviteLink", () => { - it("generates key, wraps with orgKey, calls API, caches in state, and emits URL", async () => { + it("generates key, wraps with orgKey, calls API, and caches result", async () => { const rawKey = makeKey("rawkeyB64=="); const orgKey = makeKey("orgkeyB64=="); const encryptedKey = mock(); (encryptedKey as any).encryptedString = "2.enc=|iv=|mac="; - const response = makeResponse({ code: "code1", allowedDomains: ["bitwarden.com"] }); + const response = makeResponseModel({ code: "code1", allowedDomains: ["bitwarden.com"] }); keyGenerationService.createKey.mockResolvedValue(rawKey); keyService.orgKeys$.mockReturnValue(new BehaviorSubject({ [mockOrgId]: orgKey as OrgKey })); encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey); apiService.create.mockResolvedValue(response); - const url = await sut.createInviteLink(mockUserId, mockOrgId, ["bitwarden.com"]); + await sut.createInviteLink(mockUserId, mockOrgId, ["bitwarden.com"]); expect(keyGenerationService.createKey).toHaveBeenCalledWith(256); expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(rawKey, orgKey); @@ -142,10 +163,11 @@ describe("DefaultOrganizationInviteLinkService", () => { mockOrgId, expect.objectContaining({ allowedDomains: ["bitwarden.com"] }), ); - expect(url).toBe("/#/join/code1?key=rawkeyB64=="); - const stored = await firstValueFrom(sut.inviteLink$(mockUserId)); - expect(stored).toEqual(response); + const stored = await firstValueFrom( + stateProvider.getUser(mockUserId, ORGANIZATION_INVITE_LINK_KEY).state$, + ); + expect(stored).toEqual(new OrganizationInviteLink(response)); }); it("errors when orgKey is null", async () => { @@ -159,40 +181,43 @@ describe("DefaultOrganizationInviteLinkService", () => { }); }); + describe("updateInviteLink", () => { + it("calls API update with new domains and caches result", async () => { + const response = makeResponseModel({ allowedDomains: ["updated.com"] }); + apiService.update.mockResolvedValue(response); + + await sut.updateInviteLink(mockUserId, mockOrgId, ["updated.com"]); + + expect(apiService.update).toHaveBeenCalledWith( + mockOrgId, + expect.objectContaining({ allowedDomains: ["updated.com"] }), + ); + + const stored = await firstValueFrom( + stateProvider.getUser(mockUserId, ORGANIZATION_INVITE_LINK_KEY).state$, + ); + expect(stored).toEqual(new OrganizationInviteLink(response)); + }); + }); + describe("refreshInviteLink", () => { - it("re-uses cached domains", async () => { - const cached = makeResponse({ allowedDomains: ["cached.com"] }); + it("re-uses cached domains and calls updateInviteLink", async () => { + const cached = makeInviteLink({ allowedDomains: ["cached.com"] }); await sut.upsert(mockUserId, cached); - const rawKey = makeKey("refreshed=="); - const orgKey = makeKey(); - const encryptedKey = mock(); - (encryptedKey as any).encryptedString = "2.enc=|iv=|mac="; - const response = makeResponse({ code: "refreshed", allowedDomains: ["cached.com"] }); + const response = makeResponseModel({ code: "refreshed", allowedDomains: ["cached.com"] }); + apiService.update.mockResolvedValue(response); - keyGenerationService.createKey.mockResolvedValue(rawKey); - keyService.orgKeys$.mockReturnValue(new BehaviorSubject({ [mockOrgId]: orgKey as OrgKey })); - encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey); - apiService.create.mockResolvedValue(response); + await sut.refreshInviteLink(mockUserId, mockOrgId); - const url = await sut.refreshInviteLink(mockUserId, mockOrgId); - - expect(apiService.create).toHaveBeenCalledWith( + expect(apiService.update).toHaveBeenCalledWith( mockOrgId, expect.objectContaining({ allowedDomains: ["cached.com"] }), ); - expect(url).toBe("/#/join/refreshed?key=refreshed=="); }); it("falls back to empty domains when no cache, propagating the domain validation error", async () => { - const rawKey = makeKey(); - const orgKey = makeKey(); - const encryptedKey = mock(); - (encryptedKey as any).encryptedString = "2.enc=|iv=|mac="; - - keyGenerationService.createKey.mockResolvedValue(rawKey); - keyService.orgKeys$.mockReturnValue(new BehaviorSubject({ [mockOrgId]: orgKey as OrgKey })); - encryptService.wrapSymmetricKey.mockResolvedValue(encryptedKey); + apiService.get.mockRejectedValue(new ErrorResponse(null, 404)); await expect(sut.refreshInviteLink(mockUserId, mockOrgId)).rejects.toThrow( "At least one allowed domain is required.", @@ -201,34 +226,50 @@ describe("DefaultOrganizationInviteLinkService", () => { }); describe("reconstructUrl", () => { - it("calls API, unwraps key, caches, and emits URL", async () => { - const response = makeResponse({ + it("uses cached invite link to unwrap key and build URL", async () => { + const inviteLink = makeInviteLink({ code: "reconstruct", encryptedInviteKey: "2.enc=|iv=|mac=", }); + await sut.upsert(mockUserId, inviteLink); + const orgKey = makeKey(); const rawKey = makeKey("unwrapped=="); - apiService.get.mockResolvedValue(response); keyService.orgKeys$.mockReturnValue(new BehaviorSubject({ [mockOrgId]: orgKey as OrgKey })); encryptService.unwrapSymmetricKey.mockResolvedValue(rawKey); const url = await sut.reconstructUrl(mockUserId, mockOrgId); - expect(apiService.get).toHaveBeenCalledWith(mockOrgId); expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(expect.any(EncString), orgKey); expect(url).toBe("/#/join/reconstruct?key=unwrapped=="); - - const stored = await firstValueFrom(sut.inviteLink$(mockUserId)); - expect(stored).toEqual(response); }); - it("returns undefined when API returns null", async () => { - apiService.get.mockResolvedValue(null); + it("fetches from API when cache is empty, then builds URL", async () => { + const response = makeResponseModel({ + code: "reconstruct", + encryptedInviteKey: "2.enc=|iv=|mac=", + }); + apiService.get.mockResolvedValue(response); + + const orgKey = makeKey(); + const rawKey = makeKey("unwrapped=="); + + keyService.orgKeys$.mockReturnValue(new BehaviorSubject({ [mockOrgId]: orgKey as OrgKey })); + encryptService.unwrapSymmetricKey.mockResolvedValue(rawKey); const url = await sut.reconstructUrl(mockUserId, mockOrgId); - expect(url).toBeUndefined(); + expect(apiService.get).toHaveBeenCalledWith(mockOrgId); + expect(url).toBe("/#/join/reconstruct?key=unwrapped=="); + }); + + it("throws when no invite link exists", async () => { + apiService.get.mockRejectedValue(new ErrorResponse(null, 404)); + + await expect(sut.reconstructUrl(mockUserId, mockOrgId)).rejects.toThrow( + "Organization does not have an invite link to reconstruct", + ); }); }); }); From 82973acc10a69d506dd354120fc1703be7294b53 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 6 May 2026 14:03:09 -0400 Subject: [PATCH 8/8] fix tests --- .../default-organization-invite-link.service.spec.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts b/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts index f3b9ae116743..9c1c80ab7aed 100644 --- a/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts +++ b/libs/organization-invite-link/src/services/default-organization-invite-link.service.spec.ts @@ -94,13 +94,6 @@ describe("DefaultOrganizationInviteLinkService", () => { expect(value).toBeUndefined(); }); - it("rethrows non-404 API errors", async () => { - const serverError = new ErrorResponse(null, 500); - apiService.get.mockRejectedValue(serverError); - - await expect(firstValueFrom(sut.inviteLink$(mockUserId, mockOrgId))).rejects.toThrow(); - }); - it("emits cached value without calling API again", async () => { const inviteLink = makeInviteLink(); await sut.upsert(mockUserId, inviteLink);