1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-03-27 10:28:48 -05:00
committed by GitHub
74 changed files with 1357 additions and 206 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.3.1",
"version": "2025.3.2",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -95,7 +95,7 @@
</bit-card>
</bit-section>
<bit-section>
<bit-section disableMargin>
<bit-section-header>
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>
</bit-section-header>

View File

@@ -209,7 +209,7 @@
</bit-form-field>
</bit-card>
</bit-section>
<bit-section>
<bit-section [disableMargin]="!blockBrowserInjectionsByDomainEnabled">
<bit-section-header>
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
</bit-section-header>
@@ -270,7 +270,7 @@
</bit-form-field>
</bit-card>
</bit-section>
<bit-section *ngIf="blockBrowserInjectionsByDomainEnabled">
<bit-section *ngIf="blockBrowserInjectionsByDomainEnabled" disableMargin>
<bit-item>
<a bit-item-content routerLink="/blocked-domains">{{ "blockedDomains" | i18n }}</a>
<i slot="end" class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>

View File

@@ -47,7 +47,7 @@
</bit-form-control>
</bit-card>
</bit-section>
<bit-section>
<bit-section disableMargin>
<bit-item>
<a bit-item-content routerLink="/excluded-domains">{{ "excludedDomains" | i18n }}</a>
<i slot="end" class="bwi bwi-angle-right row-sub-icon" aria-hidden="true"></i>

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.1",
"version": "2025.3.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.1",
"version": "2025.3.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -19,7 +19,7 @@
[ngClass]="{ 'tw-invisible': loading }"
>
<div
class="tw-max-w-screen-sm tw-mx-auto tw-flex-1 tw-flex tw-flex-col tw-size-full"
class="tw-max-w-screen-sm tw-mx-auto tw-flex-1 tw-flex tw-flex-col tw-w-full"
[ngClass]="{ 'tw-p-3 bit-compact:tw-p-2': !disablePadding }"
>
<ng-content></ng-content>

View File

@@ -143,7 +143,10 @@ export class SshAgentService implements OnDestroy {
if (isListRequest) {
const sshCiphers = ciphers.filter(
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId == null,
);
const keys = sshCiphers.map((cipher) => {
return {
@@ -247,7 +250,7 @@ export class SshAgentService implements OnDestroy {
(cipher) =>
cipher.type === CipherType.SshKey &&
!cipher.isDeleted &&
cipher.organizationId === null,
cipher.organizationId == null,
);
const keys = sshCiphers.map((cipher) => {
return {

View File

@@ -89,8 +89,8 @@ export class VaultFilterComponent
const collapsedNodes = await firstValueFrom(this.vaultFilterService.collapsedFilterNodes$);
collapsedNodes.delete("AllCollections");
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes);
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes, userId);
}
protected async addCollectionFilter(): Promise<VaultFilterSection> {

View File

@@ -54,7 +54,7 @@
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'tw-w-4/5' : 'tw-w-3/4'">
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"

View File

@@ -45,7 +45,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -196,7 +195,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private resellerManagedOrgAlert: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
@@ -264,10 +262,6 @@ export class VaultComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
@@ -654,7 +648,7 @@ export class VaultComponent implements OnInit, OnDestroy {
);
this.resellerWarning$ = organization$.pipe(
filter((org) => org.isOwner && this.resellerManagedOrgAlert),
filter((org) => org.isOwner),
switchMap((org) =>
from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
map((metadata) => ({ org, metadata })),

View File

@@ -111,7 +111,7 @@
<ng-container *ngIf="loaded && usePlaceHolderEvents">
<div
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center"
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center tw-h-[19rem]"
>
<div
class="tw-bg-[#ffffff] tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"

View File

@@ -23,7 +23,7 @@
<input bitInput appAutofocus type="text" formControlName="name" />
<bit-hint>{{ "characterMaximum" | i18n: 100 }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-form-field *ngIf="isExternalIdVisible$ | async">
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>

View File

@@ -29,7 +29,9 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -215,6 +217,10 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupDetails$,
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
protected isExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(map((isEnabled) => !isEnabled || !!this.groupForm.get("externalId")?.value));
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
@@ -231,6 +237,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private collectionAdminService: CollectionAdminService,
private toastService: ToastService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}

View File

@@ -4,3 +4,4 @@ export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
export * from "./two-factor-auth";
export * from "./link-sso.service";

View File

@@ -0,0 +1,154 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
import { LinkSsoService } from "./link-sso.service";
describe("LinkSsoService", () => {
let sut: LinkSsoService;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockApiService: MockProxy<ApiService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
const mockEnvironment$ = new BehaviorSubject<any>({
getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"),
});
beforeEach(() => {
// Create mock implementations
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
mockApiService = mock<ApiService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockEnvironmentService = mock<EnvironmentService>();
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
// Set up environment service to return our mock environment
mockEnvironmentService.environment$ = mockEnvironment$;
// Set up API service mocks
const mockResponse = { Token: "mockSsoToken" };
mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse));
mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier");
// Set up password generation service mock
mockPasswordGenerationService.generatePassword.mockImplementation(
async (options: PasswordGeneratorOptions) => {
return "mockGeneratedPassword";
},
);
// Set up crypto function service mock
mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4]));
// Create the service under test with mock dependencies
sut = new LinkSsoService(
mockSsoLoginService,
mockApiService,
mockCryptoFunctionService,
mockEnvironmentService,
mockPasswordGenerationService,
mockPlatformUtilsService,
);
// Mock Utils.fromBufferToUrlB64
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge");
// Mock window.location
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("linkSso", () => {
it("throws an error when identifier is null", async () => {
await expect(sut.linkSso(null as unknown as string)).rejects.toThrow(
"SSO identifier is required",
);
});
it("throws an error when identifier is empty", async () => {
await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required");
});
it("calls preValidateSso with the provided identifier", async () => {
await sut.linkSso("org123");
expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123");
});
it("generates a password for code verifier", async () => {
await sut.linkSso("org123");
expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
});
});
it("sets the code verifier in the ssoLoginService", async () => {
await sut.linkSso("org123");
expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword");
});
it("generates a state and sets it in the ssoLoginService", async () => {
await sut.linkSso("org123");
const expectedState =
"mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123";
expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState);
});
it("gets the SSO user identifier from the API", async () => {
await sut.linkSso("org123");
expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled();
});
it("launches the authorize URL with the correct parameters", async () => {
await sut.linkSso("org123");
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
expect.stringContaining("https://identity.bitwarden.com/connect/authorize"),
{ sameWindow: true },
);
const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0];
expect(launchUriArg).toContain("client_id=web");
expect(launchUriArg).toContain(
"redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html",
);
expect(launchUriArg).toContain("response_type=code");
expect(launchUriArg).toContain("code_challenge=mockCodeChallenge");
expect(launchUriArg).toContain("ssoToken=mockSsoToken");
expect(launchUriArg).toContain("user_identifier=mockUserIdentifier");
});
});
});

View File

@@ -0,0 +1,91 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
/**
* Provides a service for linking SSO.
*/
export class LinkSsoService {
constructor(
private ssoLoginService: SsoLoginServiceAbstraction,
private apiService: ApiService,
private cryptoFunctionService: CryptoFunctionService,
private environmentService: EnvironmentService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) {}
/**
* Links SSO to an organization.
* Ported from the SsoComponent
* @param identifier The identifier of the organization to link to.
*/
async linkSso(identifier: string) {
if (identifier == null || identifier === "") {
throw new Error("SSO identifier is required");
}
const redirectUri = window.location.origin + "/sso-connector.html";
const clientId = "web";
const returnUri = "/settings/organizations";
const response = await this.apiService.preValidateSso(identifier);
const passwordOptions: PasswordGeneratorOptions = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
};
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
state += `_returnUri='${returnUri}'`;
state += `_identifier=${identifier}`;
// Save state
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
clientId +
"&redirect_uri=" +
encodeURIComponent(redirectUri) +
"&" +
"response_type=code&scope=api offline_access&" +
"state=" +
state +
"&code_challenge=" +
codeChallenge +
"&" +
"code_challenge_method=S256&response_mode=query&" +
"domain_hint=" +
encodeURIComponent(identifier) +
"&ssoToken=" +
encodeURIComponent(response.token);
const userIdentifier = await this.apiService.getSsoUserIdentifier();
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
}
}

View File

@@ -11,8 +11,6 @@ import { BillingSourceResponse } from "@bitwarden/common/billing/models/response
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
@@ -24,15 +22,12 @@ import { FreeTrial } from "../types/free-trial";
@Injectable({ providedIn: "root" })
export class TrialFlowService {
private resellerManagedOrgAlert: boolean;
constructor(
private i18nService: I18nService,
protected dialogService: DialogService,
private router: Router,
protected billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
) {}
checkForOrgsWithUpcomingPaymentIssues(
organization: Organization,
@@ -98,10 +93,6 @@ export class TrialFlowService {
isCanceled: boolean,
isUnpaid: boolean,
): Promise<boolean> {
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
if (!org?.isOwner && !org.providerId) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
@@ -113,7 +104,7 @@ export class TrialFlowService {
return false;
}
if (org.providerId && this.resellerManagedOrgAlert) {
if (org.providerId) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org.name),
content: { key: "suspendedManagedOrgMessage", placeholders: [org.providerName] },
@@ -134,7 +125,7 @@ export class TrialFlowService {
});
}
if (org.isOwner && isCanceled && this.resellerManagedOrgAlert) {
if (org.isOwner && isCanceled) {
await this.changePlan(org);
}
}

View File

@@ -117,6 +117,7 @@ import {
WebLoginDecryptionOptionsService,
WebTwoFactorAuthComponentService,
WebTwoFactorAuthDuoComponentService,
LinkSsoService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebTwoFactorFormCacheService } from "../auth/core/services/two-factor-auth/web-two-factor-form-cache.service";
@@ -352,6 +353,18 @@ const safeProviders: SafeProvider[] = [
useClass: WebSsoComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: LinkSsoService,
useClass: LinkSsoService,
deps: [
SsoLoginServiceAbstraction,
ApiService,
CryptoFunctionService,
EnvironmentService,
PasswordGenerationServiceAbstraction,
PlatformUtilsService,
],
}),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: WebTwoFactorAuthDuoComponentService,

View File

@@ -1,26 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Directive({
selector: "[app-link-sso]",
})
export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
@Input() organization: Organization;
returnUri = "/settings/organizations";
redirectUri = window.location.origin + "/sso-connector.html";
clientId = "web";
@HostListener("click", ["$event"])
async onClick($event: MouseEvent) {
$event.preventDefault();
await this.submit(this.returnUri, true);
}
async ngAfterContentInit() {
this.identifier = this.organization.identifier;
}
}

View File

@@ -50,10 +50,10 @@
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<a href="#" bitMenuItem app-link-sso [organization]="organization">
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
{{ "linkSso" | i18n }}
</a>
</button>
</ng-template>
</ng-container>
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import {
combineLatest,
@@ -37,6 +35,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component";
import { LinkSsoService } from "../../../../auth/core/services";
import { OptionsInput } from "../shared/components/vault-filter-section.component";
import { OrganizationFilter } from "../shared/models/vault-filter.type";
@@ -45,12 +44,12 @@ import { OrganizationFilter } from "../shared/models/vault-filter.type";
templateUrl: "organization-options.component.html",
})
export class OrganizationOptionsComponent implements OnInit, OnDestroy {
protected actionPromise: Promise<void | boolean>;
protected actionPromise?: Promise<void | boolean>;
protected resetPasswordPolicy?: Policy | undefined;
protected loaded = false;
protected hideMenu = false;
protected showLeaveOrgOption = false;
protected organization: OrganizationFilter;
protected organization!: OrganizationFilter;
private destroy$ = new Subject<void>();
@@ -72,6 +71,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private configService: ConfigService,
private organizationService: OrganizationService,
private accountService: AccountService,
private linkSsoService: LinkSsoService,
) {}
async ngOnInit() {
@@ -147,6 +147,23 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
return org?.useSso && org?.identifier;
}
/**
* Links SSO to an organization.
* @param organization The organization to link SSO to.
*/
async handleLinkSso(organization: Organization) {
try {
await this.linkSsoService.linkSso(organization.identifier);
} catch (e) {
this.logService.error(e);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
}
}
async unlinkSso(org: Organization) {
const confirmed = await this.dialogService.openSimpleDialog({
title: org.name,
@@ -165,7 +182,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("unlinkedSso"),
});
} catch (e) {
@@ -189,7 +206,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("leftOrganization"),
});
} catch (e) {
@@ -215,7 +232,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
// Remove reset password
const request = new OrganizationUserResetPasswordEnrollmentRequest();
request.masterPasswordHash = "ignored";
request.resetPasswordKey = null;
request.resetPasswordKey = "";
this.actionPromise =
this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
this.organization.id,
@@ -226,7 +243,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("withdrawPasswordResetSuccess"),
});
await this.syncService.fullSync(true);

View File

@@ -94,6 +94,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
private trialFlowService = inject(TrialFlowService);
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
protected vaultFilterService: VaultFilterService,
@@ -162,7 +163,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
filter.selectedOrganizationNode = orgNode;
}
this.vaultFilterService.setOrganizationFilter(orgNode.node);
await this.vaultFilterService.expandOrgFilter();
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.expandOrgFilter(userId);
};
applyTypeFilter = async (filterNode: TreeNode<CipherTypeFilter>): Promise<void> => {

View File

@@ -4,6 +4,7 @@ import { Observable } from "rxjs";
import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -22,16 +23,19 @@ export abstract class VaultFilterService {
folderTree$: Observable<TreeNode<FolderFilter>>;
collectionTree$: Observable<TreeNode<CollectionFilter>>;
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
expandOrgFilter: () => Promise<void>;
getOrganizationFilter: () => Observable<Organization>;
setOrganizationFilter: (organization: Organization) => void;
buildTypeTree: (
abstract getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
abstract setCollapsedFilterNodes: (
collapsedFilterNodes: Set<string>,
userId: UserId,
) => Promise<void>;
abstract expandOrgFilter: (userId: UserId) => Promise<void>;
abstract getOrganizationFilter: () => Observable<Organization>;
abstract setOrganizationFilter: (organization: Organization) => void;
abstract buildTypeTree: (
head: CipherTypeFilter,
array: CipherTypeFilter[],
) => Observable<TreeNode<CipherTypeFilter>>;
// TODO: Remove this from org vault when collection admin service adopts state management
reloadCollections?: (collections: CollectionAdminView[]) => void;
clearOrganizationFilter: () => void;
abstract reloadCollections?: (collections: CollectionAdminView[]) => void;
abstract clearOrganizationFilter: () => void;
}

View File

@@ -2,7 +2,7 @@ import {
FakeAccountService,
mockAccountServiceWith,
} from "@bitwarden/common/../spec/fake-account-service";
import { FakeActiveUserState } from "@bitwarden/common/../spec/fake-state";
import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state";
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, ReplaySubject } from "rxjs";
@@ -42,7 +42,7 @@ describe("vault filter service", () => {
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let collapsedGroupingsState: FakeActiveUserState<string[]>;
let collapsedGroupingsState: FakeSingleUserState<string[]>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
@@ -83,21 +83,21 @@ describe("vault filter service", () => {
collectionService,
accountService,
);
collapsedGroupingsState = stateProvider.activeUser.getFake(COLLAPSED_GROUPINGS);
collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS);
});
describe("collapsed filter nodes", () => {
const nodes = new Set(["1", "2"]);
it("should update the collapsedFilterNodes$", async () => {
await vaultFilterService.setCollapsedFilterNodes(nodes);
await vaultFilterService.setCollapsedFilterNodes(nodes, mockUserId);
const collapsedGroupingsState = stateProvider.activeUser.getFake(COLLAPSED_GROUPINGS);
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith([
const collapsedGroupingsState = stateProvider.singleUser.getFake(
mockUserId,
Array.from(nodes),
]);
COLLAPSED_GROUPINGS,
);
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith(Array.from(nodes));
});
it("loads from state on initialization", async () => {

View File

@@ -23,8 +23,10 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
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 { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -47,12 +49,17 @@ const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService implements VaultFilterServiceAbstraction {
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
memberOrganizations$ = this.activeUserId$.pipe(
switchMap((id) => this.organizationService.memberOrganizations$(id)),
);
collapsedFilterNodes$ = this.activeUserId$.pipe(
switchMap((id) => this.collapsedGroupingsState(id).state$),
map((state) => new Set(state)),
);
organizationTree$: Observable<TreeNode<OrganizationFilter>> = combineLatest([
this.memberOrganizations$,
this.activeUserId$.pipe(
@@ -103,11 +110,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>> = this.buildCipherTypeTree();
private collapsedGroupingsState: ActiveUserState<string[]> =
this.stateProvider.getActive(COLLAPSED_GROUPINGS);
readonly collapsedFilterNodes$: Observable<Set<string>> =
this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c)));
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
}
constructor(
protected organizationService: OrganizationService,
@@ -125,8 +130,8 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionFilter>;
}
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
await this.collapsedGroupingsState.update(() => Array.from(collapsedFilterNodes));
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>, userId: UserId): Promise<void> {
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
}
protected async getCollapsedFilterNodes(): Promise<Set<string>> {
@@ -149,13 +154,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
}
}
async expandOrgFilter() {
async expandOrgFilter(userId: UserId) {
const collapsedFilterNodes = await firstValueFrom(this.collapsedFilterNodes$);
if (!collapsedFilterNodes.has("AllVaults")) {
return;
}
collapsedFilterNodes.delete("AllVaults");
await this.setCollapsedFilterNodes(collapsedFilterNodes);
await this.setCollapsedFilterNodes(collapsedFilterNodes, userId);
}
protected async buildOrganizationTree(

View File

@@ -1,10 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core";
import { Observable, Subject, takeUntil } from "rxjs";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { map } from "rxjs/operators";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilterService } from "../../services/abstractions/vault-filter.service";
@@ -17,6 +19,7 @@ import { VaultFilter } from "../models/vault-filter.model";
})
export class VaultFilterSectionComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private activeUserId$ = getUserId(this.accountService.activeAccount$);
@Input() activeFilter: VaultFilter;
@Input() section: VaultFilterSection;
@@ -29,6 +32,7 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
constructor(
private vaultFilterService: VaultFilterService,
private injector: Injector,
private accountService: AccountService,
) {
this.vaultFilterService.collapsedFilterNodes$
.pipe(takeUntil(this.destroy$))
@@ -126,7 +130,8 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
} else {
this.collapsedFilterNodes.add(node.id);
}
await this.vaultFilterService.setCollapsedFilterNodes(this.collapsedFilterNodes);
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.setCollapsedFilterNodes(this.collapsedFilterNodes, userId);
}
// an injector is necessary to pass data into a dynamic component

View File

@@ -4,7 +4,6 @@ import { SearchModule } from "@bitwarden/components";
import { VaultFilterSharedModule } from "../../individual-vault/vault-filter/shared/vault-filter-shared.module";
import { LinkSsoDirective } from "./components/link-sso.directive";
import { OrganizationOptionsComponent } from "./components/organization-options.component";
import { VaultFilterComponent } from "./components/vault-filter.component";
import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service";
@@ -12,7 +11,7 @@ import { VaultFilterService } from "./services/vault-filter.service";
@NgModule({
imports: [VaultFilterSharedModule, SearchModule],
declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoDirective],
declarations: [VaultFilterComponent, OrganizationOptionsComponent],
exports: [VaultFilterComponent],
providers: [
{