mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows
This commit is contained in:
@@ -13,15 +13,15 @@
|
||||
|
||||
# Bitwarden Client Applications
|
||||
|
||||
This repository houses all Bitwarden client applications except the [Mobile application](https://github.com/bitwarden/mobile).
|
||||
This repository houses all Bitwarden client applications except the mobile applications ([iOS](https://github.com/bitwarden/ios) | [android](https://github.com/bitwarden/android)).
|
||||
|
||||
Please refer to the [Clients section](https://contributing.bitwarden.com/getting-started/clients/) of the [Contributing Documentation](https://contributing.bitwarden.com/) for build instructions, recommended tooling, code style tips, and lots of other great information to get you started.
|
||||
|
||||
## Related projects:
|
||||
|
||||
- [bitwarden/server](https://github.com/bitwarden/server): The core infrastructure backend (API, database, Docker, etc).
|
||||
- [bitwarden/ios](https://github.com/bitwarden/ios): Bitwarden mobile app for iOS.
|
||||
- [bitwarden/android](https://github.com/bitwarden/android): Bitwarden mobile app for Android.
|
||||
- [bitwarden/ios](https://github.com/bitwarden/ios): Bitwarden iOS Password Manager & Authenticator apps.
|
||||
- [bitwarden/android](https://github.com/bitwarden/android): Bitwarden Android Password Manager & Authenticator apps.
|
||||
- [bitwarden/directory-connector](https://github.com/bitwarden/directory-connector): A tool for syncing a directory (AD, LDAP, Azure, G Suite, Okta) to an organization.
|
||||
|
||||
# We're Hiring!
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
154
apps/web/src/app/auth/core/services/link-sso.service.spec.ts
Normal file
154
apps/web/src/app/auth/core/services/link-sso.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
91
apps/web/src/app/auth/core/services/link-sso.service.ts
Normal file
91
apps/web/src/app/auth/core/services/link-sso.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -1480,7 +1480,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
deps: [StateProvider, ApiServiceAbstraction],
|
||||
deps: [StateProvider, ApiServiceAbstraction, NotificationsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustToastServiceAbstraction,
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum FeatureFlag {
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
|
||||
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
|
||||
SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility",
|
||||
|
||||
/* Autofill */
|
||||
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
|
||||
@@ -46,7 +47,6 @@ export enum FeatureFlag {
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
@@ -70,6 +70,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
|
||||
[FeatureFlag.LimitItemDeletion]: FALSE,
|
||||
[FeatureFlag.SsoExternalIdVisibility]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
|
||||
@@ -108,7 +109,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
|
||||
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
|
||||
|
||||
@@ -24,4 +24,6 @@ export enum NotificationType {
|
||||
SyncOrganizations = 17,
|
||||
SyncOrganizationStatusChanged = 18,
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
Notification = 20,
|
||||
NotificationStatus = 21,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { import_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
|
||||
@@ -18,18 +17,16 @@ export class SshKeyExport {
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
view.privateKey = parsedKey.privateKey;
|
||||
view.publicKey = parsedKey.publicKey;
|
||||
view.keyFingerprint = parsedKey.fingerprint;
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
domain.privateKey = new EncString(parsedKey.privateKey);
|
||||
domain.publicKey = new EncString(parsedKey.publicKey);
|
||||
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
|
||||
domain.privateKey = new EncString(req.privateKey);
|
||||
domain.publicKey = new EncString(req.publicKey);
|
||||
domain.keyFingerprint = new EncString(req.keyFingerprint);
|
||||
return domain;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subject, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { NotificationsService } from "../notifications.service";
|
||||
|
||||
export class NoopNotificationsService implements NotificationsService {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]> = new Subject();
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* A service offering abilities to interact with push notifications from the server.
|
||||
*/
|
||||
export abstract class NotificationsService {
|
||||
abstract notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
/**
|
||||
* Starts automatic listening and processing of notifications, should only be called once per application,
|
||||
* or you will risk notifications being processed multiple times.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { VendorId } from "../extension";
|
||||
|
||||
import { IntegrationContext } from "./integration-context";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
@@ -8,7 +9,7 @@ import { IntegrationMetadata } from "./integration-metadata";
|
||||
|
||||
const EXAMPLE_META = Object.freeze({
|
||||
// arbitrary
|
||||
id: "simplelogin" as IntegrationId,
|
||||
id: "simplelogin" as IntegrationId & VendorId,
|
||||
name: "Example",
|
||||
// arbitrary
|
||||
extends: ["forwarder"],
|
||||
@@ -34,7 +35,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("throws when the baseurl isn't defined in metadata", () => {
|
||||
const noBaseUrl: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
selfHost: "maybe",
|
||||
@@ -56,7 +57,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("ignores settings when selfhost is 'never'", () => {
|
||||
const selfHostNever: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -71,7 +72,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("always reads the settings when selfhost is 'always'", () => {
|
||||
const selfHostAlways: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -86,7 +87,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("fails when the settings are empty and selfhost is 'always'", () => {
|
||||
const selfHostAlways: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -101,7 +102,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("reads from the metadata by default when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
@@ -117,7 +118,7 @@ describe("IntegrationContext", () => {
|
||||
|
||||
it("overrides the metadata when selfhost is 'maybe'", () => {
|
||||
const selfHostMaybe: IntegrationMetadata = {
|
||||
id: "simplelogin" as IntegrationId, // arbitrary
|
||||
id: "simplelogin" as IntegrationId & VendorId, // arbitrary
|
||||
name: "Example",
|
||||
extends: ["forwarder"], // arbitrary
|
||||
baseUrl: "example.com",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { VendorId } from "../extension";
|
||||
|
||||
import { ExtensionPointId } from "./extension-point-id";
|
||||
import { IntegrationId } from "./integration-id";
|
||||
|
||||
/** The capabilities and descriptive content for an integration */
|
||||
export type IntegrationMetadata = {
|
||||
/** Uniquely identifies the integrator. */
|
||||
id: IntegrationId;
|
||||
id: IntegrationId & VendorId;
|
||||
|
||||
/** Brand name of the integrator. */
|
||||
name: string;
|
||||
|
||||
@@ -12,7 +12,11 @@ export class DisabledSemanticLogger implements SemanticLogger {
|
||||
|
||||
error<T>(_content: Jsonify<T>, _message?: string): void {}
|
||||
|
||||
panic<T>(_content: Jsonify<T>, message?: string): never {
|
||||
throw new Error(message);
|
||||
panic<T>(content: Jsonify<T>, message?: string): never {
|
||||
if (typeof content === "string" && !message) {
|
||||
throw new Error(content);
|
||||
} else {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SemanticLogger {
|
||||
*/
|
||||
debug(message: string): void;
|
||||
|
||||
// FIXME: replace Jsonify<T> parameter with structural logging schema type
|
||||
/** Logs the content at debug priority.
|
||||
* Debug messages are used for diagnostics, and are typically disabled
|
||||
* in production builds.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||
|
||||
@@ -29,8 +30,11 @@ export const Integrations = Object.freeze({
|
||||
|
||||
const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i]));
|
||||
|
||||
export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration<ApiSettings> {
|
||||
const maybeForwarder = integrations.get(id);
|
||||
export function getForwarderConfiguration(
|
||||
id: IntegrationId | VendorId,
|
||||
): ForwarderConfiguration<ApiSettings> {
|
||||
// these casts are for compatibility; `IntegrationId` is the old form of `VendorId`
|
||||
const maybeForwarder = integrations.get(id as string as IntegrationId & VendorId);
|
||||
|
||||
if (maybeForwarder && "forwarder" in maybeForwarder) {
|
||||
return maybeForwarder as ForwarderConfiguration<ApiSettings>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import {
|
||||
ApiSettings,
|
||||
@@ -100,7 +101,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
export const AddyIo = Object.freeze({
|
||||
// integration
|
||||
id: "anonaddy" as IntegrationId,
|
||||
id: "anonaddy" as IntegrationId & VendorId,
|
||||
name: "Addy.io",
|
||||
extends: ["forwarder"],
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||
@@ -89,7 +90,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
// integration-wide configuration
|
||||
export const DuckDuckGo = Object.freeze({
|
||||
id: "duckduckgo" as IntegrationId,
|
||||
id: "duckduckgo" as IntegrationId & VendorId,
|
||||
name: "DuckDuckGo",
|
||||
baseUrl: "https://quack.duckduckgo.com/api",
|
||||
selfHost: "never",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||
@@ -159,7 +160,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
// integration-wide configuration
|
||||
export const Fastmail = Object.freeze({
|
||||
id: "fastmail" as IntegrationId,
|
||||
id: "fastmail" as IntegrationId & VendorId,
|
||||
name: "Fastmail",
|
||||
baseUrl: "https://api.fastmail.com",
|
||||
selfHost: "maybe",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||
@@ -97,7 +98,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
// integration-wide configuration
|
||||
export const FirefoxRelay = Object.freeze({
|
||||
id: "firefoxrelay" as IntegrationId,
|
||||
id: "firefoxrelay" as IntegrationId & VendorId,
|
||||
name: "Firefox Relay",
|
||||
baseUrl: "https://relay.firefox.com/api",
|
||||
selfHost: "never",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||
@@ -101,7 +102,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
export const ForwardEmail = Object.freeze({
|
||||
// integration metadata
|
||||
id: "forwardemail" as IntegrationId,
|
||||
id: "forwardemail" as IntegrationId & VendorId,
|
||||
name: "Forward Email",
|
||||
extends: ["forwarder"],
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
GENERATOR_MEMORY,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
import {
|
||||
ApiSettings,
|
||||
@@ -103,7 +104,7 @@ const forwarder = Object.freeze({
|
||||
|
||||
// integration-wide configuration
|
||||
export const SimpleLogin = Object.freeze({
|
||||
id: "simplelogin" as IntegrationId,
|
||||
id: "simplelogin" as IntegrationId & VendorId,
|
||||
name: "SimpleLogin",
|
||||
selfHost: "maybe",
|
||||
extends: ["forwarder"],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CredentialAlgorithm, CredentialType } from "./type";
|
||||
|
||||
type I18nKeyOrLiteral = string | { literal: string };
|
||||
|
||||
/** Credential generator metadata common across credential generators */
|
||||
export type AlgorithmMetadata = {
|
||||
/** Uniquely identifies the credential configuration
|
||||
@@ -23,25 +25,25 @@ export type AlgorithmMetadata = {
|
||||
/** Localization keys */
|
||||
i18nKeys: {
|
||||
/** descriptive name of the algorithm */
|
||||
name: string;
|
||||
name: I18nKeyOrLiteral;
|
||||
|
||||
/** explanatory text for the algorithm */
|
||||
description?: string;
|
||||
description?: I18nKeyOrLiteral;
|
||||
|
||||
/** labels the generate action */
|
||||
generateCredential: string;
|
||||
generateCredential: I18nKeyOrLiteral;
|
||||
|
||||
/** message informing users when the generator produces a new credential */
|
||||
credentialGenerated: string;
|
||||
credentialGenerated: I18nKeyOrLiteral;
|
||||
|
||||
/* labels the action that assigns a generated value to a domain object */
|
||||
useCredential: string;
|
||||
useCredential: I18nKeyOrLiteral;
|
||||
|
||||
/** labels the generated output */
|
||||
credentialType: string;
|
||||
credentialType: I18nKeyOrLiteral;
|
||||
|
||||
/** labels the copy output action */
|
||||
copyCredential: string;
|
||||
copyCredential: I18nKeyOrLiteral;
|
||||
};
|
||||
|
||||
/** fine-tunings for generator user experiences */
|
||||
|
||||
@@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => {
|
||||
});
|
||||
|
||||
describe("profiles[account]", () => {
|
||||
let accountProfile: CoreProfileMetadata<CatchallGenerationOptions> = null;
|
||||
let accountProfile: CoreProfileMetadata<CatchallGenerationOptions> = null!;
|
||||
beforeEach(() => {
|
||||
const profile = catchall.profiles[Profile.account];
|
||||
if (isCoreProfile(profile)) {
|
||||
if (isCoreProfile(profile!)) {
|
||||
accountProfile = profile;
|
||||
} else {
|
||||
throw new Error("this branch should never run");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,75 @@
|
||||
// Forwarders are pending integration with the extension API
|
||||
//
|
||||
// They use the 300-block of weights and derive their metadata
|
||||
// using logic similar to `toCredentialGeneratorConfiguration`
|
||||
import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type";
|
||||
import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
|
||||
|
||||
import { getForwarderConfiguration } from "../../data";
|
||||
import { EmailDomainSettings, EmailPrefixSettings } from "../../engine";
|
||||
import { Forwarder } from "../../engine/forwarder";
|
||||
import { GeneratorDependencyProvider } from "../../types";
|
||||
import { Profile, Type } from "../data";
|
||||
import { GeneratorMetadata } from "../generator-metadata";
|
||||
import { ForwarderProfileMetadata } from "../profile-metadata";
|
||||
|
||||
// These options are used by all forwarders; each forwarder uses a different set,
|
||||
// as defined by `GeneratorMetadata<T>.capabilities.fields`.
|
||||
type ForwarderOptions = Partial<EmailDomainSettings & EmailPrefixSettings & SelfHostedApiSettings>;
|
||||
|
||||
// update the extension metadata
|
||||
export function toForwarderMetadata(
|
||||
extension: ExtensionMetadata,
|
||||
): GeneratorMetadata<ForwarderOptions> {
|
||||
if (extension.site.id !== "forwarder") {
|
||||
throw new Error(
|
||||
`expected forwarder extension; received ${extension.site.id} (${extension.product.vendor.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
const name = { literal: extension.product.name ?? extension.product.vendor.name };
|
||||
|
||||
const generator: GeneratorMetadata<ForwarderOptions> = {
|
||||
id: { forwarder: extension.product.vendor.id },
|
||||
category: Type.email,
|
||||
weight: 300,
|
||||
i18nKeys: {
|
||||
name,
|
||||
description: "forwardedEmailDesc",
|
||||
generateCredential: "generateEmail",
|
||||
credentialGenerated: "emailGenerated",
|
||||
useCredential: "useThisEmail",
|
||||
credentialType: "email",
|
||||
copyCredential: "copyEmail",
|
||||
},
|
||||
capabilities: {
|
||||
autogenerate: false,
|
||||
fields: [...extension.requestedFields],
|
||||
},
|
||||
engine: {
|
||||
create(dependencies: GeneratorDependencyProvider) {
|
||||
const config = getForwarderConfiguration(extension.product.vendor.id);
|
||||
return new Forwarder(config, dependencies.client, dependencies.i18nService);
|
||||
},
|
||||
},
|
||||
profiles: {
|
||||
[Profile.account]: {
|
||||
type: "extension",
|
||||
site: "forwarder",
|
||||
storage: {
|
||||
key: "forwarder",
|
||||
frame: 512,
|
||||
options: {
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
} satisfies ExtensionStorageKey<ForwarderOptions>,
|
||||
constraints: {
|
||||
default: {},
|
||||
create() {
|
||||
return new IdentityConstraint<ForwarderOptions>();
|
||||
},
|
||||
},
|
||||
} satisfies ForwarderProfileMetadata<ForwarderOptions>,
|
||||
},
|
||||
};
|
||||
|
||||
return generator;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => {
|
||||
});
|
||||
|
||||
describe("profiles[account]", () => {
|
||||
let accountProfile: CoreProfileMetadata<SubaddressGenerationOptions> = null;
|
||||
let accountProfile: CoreProfileMetadata<SubaddressGenerationOptions> = null!;
|
||||
beforeEach(() => {
|
||||
const profile = plusAddress.profiles[Profile.account];
|
||||
if (isCoreProfile(profile)) {
|
||||
if (isCoreProfile(profile!)) {
|
||||
accountProfile = profile;
|
||||
} else {
|
||||
throw new Error("this branch should never run");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { AlgorithmsByType as ABT } from "./data";
|
||||
import {
|
||||
Algorithm as AlgorithmData,
|
||||
AlgorithmsByType as AlgorithmsByTypeData,
|
||||
Type as TypeData,
|
||||
} from "./data";
|
||||
import { CredentialType, CredentialAlgorithm } from "./type";
|
||||
|
||||
// `CredentialAlgorithm` is defined in terms of `ABT`; supplying
|
||||
// type information in the barrel file breaks a circular dependency.
|
||||
/** Credential generation algorithms grouped by purpose. */
|
||||
export const AlgorithmsByType: Record<CredentialType, ReadonlyArray<CredentialAlgorithm>> = ABT;
|
||||
export const AlgorithmsByType: Record<
|
||||
CredentialType,
|
||||
ReadonlyArray<CredentialAlgorithm>
|
||||
> = AlgorithmsByTypeData;
|
||||
export const Algorithms: ReadonlyArray<CredentialAlgorithm> = Object.freeze(
|
||||
Object.values(AlgorithmData),
|
||||
);
|
||||
export const Types: ReadonlyArray<CredentialType> = Object.freeze(Object.values(TypeData));
|
||||
|
||||
export { Profile, Type } from "./data";
|
||||
export { Profile, Type, Algorithm } from "./data";
|
||||
export { toForwarderMetadata } from "./email/forwarder";
|
||||
export { GeneratorMetadata } from "./generator-metadata";
|
||||
export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata";
|
||||
export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type";
|
||||
|
||||
@@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => {
|
||||
});
|
||||
|
||||
describe("profiles[account]", () => {
|
||||
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> = null;
|
||||
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> | null = null;
|
||||
beforeEach(() => {
|
||||
const profile = effPassphrase.profiles[Profile.account];
|
||||
if (isCoreProfile(profile)) {
|
||||
if (isCoreProfile(profile!)) {
|
||||
accountProfile = profile;
|
||||
} else {
|
||||
accountProfile = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("storage.options.deserializer", () => {
|
||||
it("returns its input", () => {
|
||||
const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial };
|
||||
const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial };
|
||||
|
||||
const result = accountProfile.storage.options.deserializer(value);
|
||||
const result = accountProfile!.storage.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
@@ -46,15 +48,15 @@ describe("password - eff words generator metadata", () => {
|
||||
// enclosed behaviors change.
|
||||
|
||||
it("creates a passphrase policy constraints", () => {
|
||||
const context = { defaultConstraints: accountProfile.constraints.default };
|
||||
const context = { defaultConstraints: accountProfile!.constraints.default };
|
||||
|
||||
const constraints = accountProfile.constraints.create([], context);
|
||||
const constraints = accountProfile!.constraints.create([], context);
|
||||
|
||||
expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints);
|
||||
});
|
||||
|
||||
it("forwards the policy to the constraints", () => {
|
||||
const context = { defaultConstraints: accountProfile.constraints.default };
|
||||
const context = { defaultConstraints: accountProfile!.constraints.default };
|
||||
const policies = [
|
||||
{
|
||||
type: PolicyType.PasswordGenerator,
|
||||
@@ -66,13 +68,13 @@ describe("password - eff words generator metadata", () => {
|
||||
},
|
||||
] as Policy[];
|
||||
|
||||
const constraints = accountProfile.constraints.create(policies, context);
|
||||
const constraints = accountProfile!.constraints.create(policies, context);
|
||||
|
||||
expect(constraints.constraints.numWords.min).toEqual(6);
|
||||
expect(constraints.constraints.numWords?.min).toEqual(6);
|
||||
});
|
||||
|
||||
it("combines multiple policies in the constraints", () => {
|
||||
const context = { defaultConstraints: accountProfile.constraints.default };
|
||||
const context = { defaultConstraints: accountProfile!.constraints.default };
|
||||
const policies = [
|
||||
{
|
||||
type: PolicyType.PasswordGenerator,
|
||||
@@ -92,10 +94,10 @@ describe("password - eff words generator metadata", () => {
|
||||
},
|
||||
] as Policy[];
|
||||
|
||||
const constraints = accountProfile.constraints.create(policies, context);
|
||||
const constraints = accountProfile!.constraints.create(policies, context);
|
||||
|
||||
expect(constraints.constraints.numWords.min).toEqual(6);
|
||||
expect(constraints.constraints.capitalize.requiredValue).toEqual(true);
|
||||
expect(constraints.constraints.numWords?.min).toEqual(6);
|
||||
expect(constraints.constraints.capitalize?.requiredValue).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => {
|
||||
});
|
||||
|
||||
describe("profiles[account]", () => {
|
||||
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = null;
|
||||
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = null!;
|
||||
beforeEach(() => {
|
||||
const profile = password.profiles[Profile.account];
|
||||
if (isCoreProfile(profile)) {
|
||||
if (isCoreProfile(profile!)) {
|
||||
accountProfile = profile;
|
||||
} else {
|
||||
throw new Error("this branch should never run");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,7 +71,7 @@ describe("password - characters generator metadata", () => {
|
||||
|
||||
const constraints = accountProfile.constraints.create(policies, context);
|
||||
|
||||
expect(constraints.constraints.length.min).toEqual(10);
|
||||
expect(constraints.constraints.length?.min).toEqual(10);
|
||||
});
|
||||
|
||||
it("combines multiple policies in the constraints", () => {
|
||||
@@ -97,8 +99,8 @@ describe("password - characters generator metadata", () => {
|
||||
|
||||
const constraints = accountProfile.constraints.create(policies, context);
|
||||
|
||||
expect(constraints.constraints.length.min).toEqual(14);
|
||||
expect(constraints.constraints.special.requiredValue).toEqual(true);
|
||||
expect(constraints.constraints.length?.min).toEqual(14);
|
||||
expect(constraints.constraints.special?.requiredValue).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => {
|
||||
});
|
||||
|
||||
describe("profiles[account]", () => {
|
||||
let accountProfile: CoreProfileMetadata<EffUsernameGenerationOptions> = null;
|
||||
let accountProfile: CoreProfileMetadata<EffUsernameGenerationOptions> = null!;
|
||||
beforeEach(() => {
|
||||
const profile = effWordList.profiles[Profile.account];
|
||||
if (isCoreProfile(profile)) {
|
||||
if (isCoreProfile(profile!)) {
|
||||
accountProfile = profile;
|
||||
} else {
|
||||
throw new Error("this branch should never run");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,41 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
|
||||
import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "..";
|
||||
import {
|
||||
CredentialAlgorithm as LegacyAlgorithm,
|
||||
EmailAlgorithms,
|
||||
PasswordAlgorithms,
|
||||
UsernameAlgorithms,
|
||||
} from "..";
|
||||
import { CredentialAlgorithm } from "../metadata";
|
||||
|
||||
/** Reduces policies to a set of available algorithms
|
||||
* @param policies the policies to reduce
|
||||
* @returns the resulting `AlgorithmAvailabilityPolicy`
|
||||
*/
|
||||
export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] {
|
||||
export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] {
|
||||
const overridePassword = policies
|
||||
.filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled)
|
||||
.reduce(
|
||||
(type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)),
|
||||
null as LegacyAlgorithm,
|
||||
);
|
||||
|
||||
const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms];
|
||||
if (overridePassword) {
|
||||
policy.push(overridePassword);
|
||||
} else {
|
||||
policy.push(...PasswordAlgorithms);
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** Reduces policies to a set of available algorithms
|
||||
* @param policies the policies to reduce
|
||||
* @returns the resulting `AlgorithmAvailabilityPolicy`
|
||||
*/
|
||||
export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] {
|
||||
const overridePassword = policies
|
||||
.filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled)
|
||||
.reduce(
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
|
||||
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
|
||||
import {
|
||||
ExtensionMetadata,
|
||||
ExtensionSite,
|
||||
Site,
|
||||
SiteId,
|
||||
SiteMetadata,
|
||||
} from "@bitwarden/common/tools/extension";
|
||||
import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service";
|
||||
import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden";
|
||||
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||
import { deepFreeze } from "@bitwarden/common/tools/util";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec";
|
||||
import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata";
|
||||
import catchall from "../metadata/email/catchall";
|
||||
import plusAddress from "../metadata/email/plus-address";
|
||||
import passphrase from "../metadata/password/eff-word-list";
|
||||
import password from "../metadata/password/random-password";
|
||||
import effWordList from "../metadata/username/eff-word-list";
|
||||
import { CredentialPreference } from "../types";
|
||||
|
||||
import { PREFERENCES } from "./credential-preferences";
|
||||
import { GeneratorMetadataProvider } from "./generator-metadata-provider";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomeAccount = {
|
||||
id: SomeUser,
|
||||
email: "someone@example.com",
|
||||
emailVerified: true,
|
||||
name: "Someone",
|
||||
};
|
||||
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
|
||||
|
||||
const SomeEncryptor: UserEncryptor = {
|
||||
userId: SomeUser,
|
||||
|
||||
encrypt(secret) {
|
||||
const tmp: any = secret;
|
||||
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
|
||||
decrypt(secret) {
|
||||
const tmp: any = JSON.parse(secret.encryptedString!);
|
||||
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
|
||||
},
|
||||
};
|
||||
|
||||
const SomeAccountService = new FakeAccountService({
|
||||
[SomeUser]: SomeAccount,
|
||||
});
|
||||
|
||||
const SomeStateProvider = new FakeStateProvider(SomeAccountService);
|
||||
|
||||
const SystemProvider = {
|
||||
encryptor: {
|
||||
userEncryptor$: () => {
|
||||
return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable();
|
||||
},
|
||||
organizationEncryptor$() {
|
||||
throw new Error("`organizationEncryptor$` should never be invoked.");
|
||||
},
|
||||
} as LegacyEncryptorProvider,
|
||||
state: SomeStateProvider,
|
||||
log: disabledSemanticLoggerProvider,
|
||||
} as UserStateSubjectDependencyProvider;
|
||||
|
||||
const SomeSiteId: SiteId = Site.forwarder;
|
||||
|
||||
const SomeSite: SiteMetadata = Object.freeze({
|
||||
id: SomeSiteId,
|
||||
availableFields: [],
|
||||
});
|
||||
|
||||
const SomePolicyService = mock<PolicyService>();
|
||||
|
||||
const SomeExtensionService = mock<ExtensionService>();
|
||||
|
||||
const ApplicationProvider = {
|
||||
/** Policy configured by the administrative console */
|
||||
policy: SomePolicyService,
|
||||
|
||||
/** Client extension metadata and profile access */
|
||||
extension: SomeExtensionService,
|
||||
|
||||
/** Event monitoring and diagnostic interfaces */
|
||||
log: disabledSemanticLoggerProvider,
|
||||
} as SystemServiceProvider;
|
||||
|
||||
describe("GeneratorMetadataProvider", () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
SomeExtensionService.site.mockImplementation(() => new ExtensionSite(SomeSite, new Map()));
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("throws when the forwarder site isn't defined by the extension service", () => {
|
||||
SomeExtensionService.site.mockReturnValue(undefined);
|
||||
expect(() => new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [])).toThrow(
|
||||
"forwarder extension site not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata", () => {
|
||||
it("returns algorithm metadata", async () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
|
||||
password,
|
||||
]);
|
||||
|
||||
const metadata = provider.metadata(password.id);
|
||||
|
||||
expect(metadata).toEqual(password);
|
||||
});
|
||||
|
||||
it("returns forwarder metadata", async () => {
|
||||
const extensionMetadata: ExtensionMetadata = {
|
||||
site: SomeSite,
|
||||
product: { vendor: Bitwarden },
|
||||
host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
|
||||
requestedFields: [],
|
||||
};
|
||||
const application = {
|
||||
...ApplicationProvider,
|
||||
extension: mock<ExtensionService>({
|
||||
site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
|
||||
}),
|
||||
};
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
|
||||
|
||||
const metadata = provider.metadata({ forwarder: Bitwarden.id });
|
||||
|
||||
expect(metadata.id).toEqual({ forwarder: Bitwarden.id });
|
||||
});
|
||||
|
||||
it("panics when metadata not found", async () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
expect(() => provider.metadata("not found" as any)).toThrow("metadata not found");
|
||||
});
|
||||
|
||||
it("panics when an extension not found", async () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
expect(() => provider.metadata({ forwarder: "not found" as any })).toThrow(
|
||||
"extension not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("types", () => {
|
||||
it("returns the credential types", async () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const result = provider.types();
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(Types));
|
||||
});
|
||||
});
|
||||
|
||||
describe("algorithms", () => {
|
||||
it("returns the password category's algorithms", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const result = provider.algorithms({ type: Type.password });
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.password]));
|
||||
});
|
||||
|
||||
it("returns the username category's algorithms", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const result = provider.algorithms({ type: Type.username });
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.username]));
|
||||
});
|
||||
|
||||
it("returns the email category's algorithms", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const result = provider.algorithms({ type: Type.email });
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.email]));
|
||||
});
|
||||
|
||||
it("includes forwarder vendors in the email category's algorithms", () => {
|
||||
const extensionMetadata: ExtensionMetadata = {
|
||||
site: SomeSite,
|
||||
product: { vendor: Bitwarden },
|
||||
host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
|
||||
requestedFields: [],
|
||||
};
|
||||
const application = {
|
||||
...ApplicationProvider,
|
||||
extension: mock<ExtensionService>({
|
||||
site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
|
||||
}),
|
||||
};
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
|
||||
|
||||
const result = provider.algorithms({ type: Type.email });
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }]));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Algorithm.catchall],
|
||||
[Algorithm.passphrase],
|
||||
[Algorithm.password],
|
||||
[Algorithm.plusAddress],
|
||||
[Algorithm.username],
|
||||
])("returns explicit algorithms (=%p)", (algorithm) => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const result = provider.algorithms({ algorithm });
|
||||
|
||||
expect(result).toEqual([algorithm]);
|
||||
});
|
||||
|
||||
it("returns explicit forwarders", () => {
|
||||
const extensionMetadata: ExtensionMetadata = {
|
||||
site: SomeSite,
|
||||
product: { vendor: Bitwarden },
|
||||
host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
|
||||
requestedFields: [],
|
||||
};
|
||||
const application = {
|
||||
...ApplicationProvider,
|
||||
extension: mock<ExtensionService>({
|
||||
site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
|
||||
}),
|
||||
};
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
|
||||
|
||||
const result = provider.algorithms({ algorithm: { forwarder: Bitwarden.id } });
|
||||
|
||||
expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }]));
|
||||
});
|
||||
|
||||
it("returns an empty array when the algorithm is invalid", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
// `any` cast required because this test subverts the type system
|
||||
const result = provider.algorithms({ algorithm: "an invalid algorithm" as any });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when the forwarder is invalid", () => {
|
||||
const extensionMetadata: ExtensionMetadata = {
|
||||
site: SomeSite,
|
||||
product: { vendor: Bitwarden },
|
||||
host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" },
|
||||
requestedFields: [],
|
||||
};
|
||||
const application = {
|
||||
...ApplicationProvider,
|
||||
extension: mock<ExtensionService>({
|
||||
site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])),
|
||||
}),
|
||||
};
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, application, []);
|
||||
|
||||
// `any` cast required because this test subverts the type system
|
||||
const result = provider.algorithms({
|
||||
algorithm: { forwarder: "an invalid forwarder" as any },
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("panics when neither an algorithm nor a category is specified", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
// `any` cast required because this test subverts the type system
|
||||
expect(() => provider.algorithms({} as any)).toThrow("algorithm or type required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("algorithms$", () => {
|
||||
it.each([
|
||||
[Algorithm.catchall, catchall],
|
||||
[Algorithm.username, effWordList],
|
||||
[Algorithm.password, password],
|
||||
])("gets a specific algorithm", async (algorithm, metadata) => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
|
||||
metadata,
|
||||
]);
|
||||
const result = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
|
||||
provider.algorithms$({ algorithm }, { account$: SomeAccount$ }).subscribe(result);
|
||||
|
||||
await expect(firstValueFrom(result)).resolves.toEqual([algorithm]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Type.email, [catchall, plusAddress]],
|
||||
[Type.username, [effWordList]],
|
||||
[Type.password, [password, passphrase]],
|
||||
])("gets a category of algorithms", async (category, metadata) => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata);
|
||||
const result = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
|
||||
provider.algorithms$({ type: category }, { account$: SomeAccount$ }).subscribe(result);
|
||||
|
||||
const expectedAlgorithms = expect.arrayContaining(metadata.map((m) => m.id));
|
||||
await expect(firstValueFrom(result)).resolves.toEqual(expectedAlgorithms);
|
||||
});
|
||||
|
||||
it("omits algorithms blocked by policy", async () => {
|
||||
const policy = new Policy({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
enabled: true,
|
||||
data: {
|
||||
overridePasswordType: Algorithm.password,
|
||||
},
|
||||
} as any);
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([policy]));
|
||||
const metadata = [password, passphrase];
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata);
|
||||
const algorithmResult = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
const categoryResult = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
|
||||
provider
|
||||
.algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ })
|
||||
.subscribe(algorithmResult);
|
||||
provider
|
||||
.algorithms$({ type: Type.password }, { account$: SomeAccount$ })
|
||||
.subscribe(categoryResult);
|
||||
|
||||
await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]);
|
||||
await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]);
|
||||
});
|
||||
|
||||
it("omits algorithms whose metadata is unavailable", async () => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
|
||||
password,
|
||||
]);
|
||||
const algorithmResult = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
const categoryResult = new ReplaySubject<CredentialAlgorithm[]>(1);
|
||||
|
||||
provider
|
||||
.algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ })
|
||||
.subscribe(algorithmResult);
|
||||
provider
|
||||
.algorithms$({ type: Type.password }, { account$: SomeAccount$ })
|
||||
.subscribe(categoryResult);
|
||||
|
||||
await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]);
|
||||
await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]);
|
||||
});
|
||||
|
||||
it("panics when neither algorithm nor category are specified", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
expect(() => provider.algorithms$({} as any, { account$: SomeAccount$ })).toThrow(
|
||||
"algorithm or type required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preference$", () => {
|
||||
const preferences: CredentialPreference = deepFreeze({
|
||||
[Type.email]: { algorithm: Algorithm.catchall, updated: new Date() },
|
||||
[Type.username]: { algorithm: Algorithm.username, updated: new Date() },
|
||||
[Type.password]: { algorithm: Algorithm.password, updated: new Date() },
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await SomeStateProvider.setUserState(PREFERENCES, preferences, SomeAccount.id);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[Type.email, catchall],
|
||||
[Type.username, effWordList],
|
||||
[Type.password, password],
|
||||
])("emits the user's %s preference", async (type, metadata) => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
|
||||
metadata,
|
||||
]);
|
||||
const result = new ReplaySubject<CredentialAlgorithm | undefined>(1);
|
||||
|
||||
provider.preference$(type, { account$: SomeAccount$ }).subscribe(result);
|
||||
|
||||
await expect(firstValueFrom(result)).resolves.toEqual(preferences[type].algorithm);
|
||||
});
|
||||
|
||||
it("emits a default when the user's preference is unavailable", async () => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [
|
||||
plusAddress,
|
||||
]);
|
||||
const result = new ReplaySubject<CredentialAlgorithm | undefined>(1);
|
||||
|
||||
// precondition: the preferred email is excluded from the provided metadata
|
||||
expect(preferences.email.algorithm).not.toEqual(plusAddress.id);
|
||||
|
||||
provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result);
|
||||
|
||||
await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id);
|
||||
});
|
||||
|
||||
it("emits undefined when the user's preference is unavailable and there is no metadata", async () => {
|
||||
SomePolicyService.policiesByType$.mockReturnValue(new BehaviorSubject([]));
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
const result = new ReplaySubject<CredentialAlgorithm | undefined>(1);
|
||||
|
||||
provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result);
|
||||
|
||||
await expect(firstValueFrom(result)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preferences", () => {
|
||||
it("returns a user state subject", () => {
|
||||
const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []);
|
||||
|
||||
const subject = provider.preferences({ account$: SomeAccount$ });
|
||||
|
||||
expect(subject).toBeInstanceOf(UserStateSubject);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
Observable,
|
||||
combineLatestWith,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BoundDependency } from "@bitwarden/common/tools/dependencies";
|
||||
import { ExtensionSite } from "@bitwarden/common/tools/extension";
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { anyComplete, pin } from "@bitwarden/common/tools/rx";
|
||||
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||
|
||||
import {
|
||||
GeneratorMetadata,
|
||||
AlgorithmsByType,
|
||||
CredentialAlgorithm,
|
||||
CredentialType,
|
||||
isForwarderExtensionId,
|
||||
toForwarderMetadata,
|
||||
Type,
|
||||
Algorithms,
|
||||
Types,
|
||||
} from "../metadata";
|
||||
import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy";
|
||||
import { CredentialPreference } from "../types";
|
||||
import {
|
||||
AlgorithmRequest,
|
||||
TypeRequest,
|
||||
MetadataRequest,
|
||||
isAlgorithmRequest,
|
||||
isTypeRequest,
|
||||
} from "../types/metadata-request";
|
||||
|
||||
import { PREFERENCES } from "./credential-preferences";
|
||||
|
||||
/** Surfaces contextual information to credential generators */
|
||||
export class GeneratorMetadataProvider {
|
||||
/** Instantiates the context provider
|
||||
* @param system dependency providers for user state subjects
|
||||
* @param application dependency providers for system services
|
||||
*/
|
||||
constructor(
|
||||
private readonly system: UserStateSubjectDependencyProvider,
|
||||
private readonly application: SystemServiceProvider,
|
||||
algorithms: ReadonlyArray<GeneratorMetadata<object>>,
|
||||
) {
|
||||
this.log = system.log({ type: "GeneratorMetadataProvider" });
|
||||
|
||||
const site = application.extension.site("forwarder");
|
||||
if (!site) {
|
||||
this.log.panic("forwarder extension site not found");
|
||||
}
|
||||
this.site = site;
|
||||
|
||||
this._metadata = new Map(algorithms.map((a) => [a.id, a] as const));
|
||||
}
|
||||
|
||||
private readonly site: ExtensionSite;
|
||||
private readonly log: SemanticLogger;
|
||||
|
||||
private _metadata: Map<CredentialAlgorithm, GeneratorMetadata<unknown & object>>;
|
||||
|
||||
/** Retrieve an algorithm's generator metadata
|
||||
* @param algorithm identifies the algorithm
|
||||
* @returns the algorithm's generator metadata
|
||||
* @throws when the algorithm doesn't identify a known metadata entry
|
||||
*/
|
||||
metadata(algorithm: CredentialAlgorithm) {
|
||||
let result = null;
|
||||
if (isForwarderExtensionId(algorithm)) {
|
||||
const extension = this.site.extensions.get(algorithm.forwarder);
|
||||
if (!extension) {
|
||||
this.log.panic(algorithm, "extension not found");
|
||||
}
|
||||
|
||||
result = toForwarderMetadata(extension);
|
||||
} else {
|
||||
result = this._metadata.get(algorithm);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
this.log.panic({ algorithm }, "metadata not found");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** retrieve credential types */
|
||||
types(): ReadonlyArray<CredentialType> {
|
||||
return Types;
|
||||
}
|
||||
|
||||
/** Retrieve the credential algorithm ids that match the request.
|
||||
* @param requested when this has a `type` property, the method
|
||||
* returns all algorithms with the same credential type. When this has an `algorithm`
|
||||
* property, the method returns 0 or 1 matching algorithms.
|
||||
* @returns the matching algorithms. This method always returns an array;
|
||||
* the array is empty when no algorithms match the input criteria.
|
||||
* @throws when neither `requested.algorithm` nor `requested.type` contains
|
||||
* a value.
|
||||
* @remarks this method enforces technical requirements only.
|
||||
* If you want these algorithms with policy controls applied, use `algorithms$`.
|
||||
*/
|
||||
algorithms(requested: AlgorithmRequest): CredentialAlgorithm[];
|
||||
algorithms(requested: TypeRequest): CredentialAlgorithm[];
|
||||
algorithms(requested: MetadataRequest): CredentialAlgorithm[] {
|
||||
let algorithms: CredentialAlgorithm[];
|
||||
if (isTypeRequest(requested)) {
|
||||
let forwarders: CredentialAlgorithm[] = [];
|
||||
if (requested.type === Type.email) {
|
||||
forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder }));
|
||||
}
|
||||
|
||||
algorithms = AlgorithmsByType[requested.type].concat(forwarders);
|
||||
} else if (isAlgorithmRequest(requested) && isForwarderExtensionId(requested.algorithm)) {
|
||||
algorithms = this.site.extensions.has(requested.algorithm.forwarder)
|
||||
? [requested.algorithm]
|
||||
: [];
|
||||
} else if (isAlgorithmRequest(requested)) {
|
||||
algorithms = Algorithms.includes(requested.algorithm) ? [requested.algorithm] : [];
|
||||
} else {
|
||||
this.log.panic(requested, "algorithm or type required");
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
// emits a function that returns `true` when the input algorithm is available
|
||||
private isAvailable$(
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): Observable<(a: CredentialAlgorithm) => boolean> {
|
||||
const id$ = dependencies.account$.pipe(
|
||||
map((account) => account.id),
|
||||
pin(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
const available$ = id$.pipe(
|
||||
switchMap((id) => {
|
||||
const policies$ = this.application.policy
|
||||
.policiesByType$(PolicyType.PasswordGenerator, id)
|
||||
.pipe(
|
||||
map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))),
|
||||
map((p) => new Set(p)),
|
||||
// complete policy emissions otherwise `switchMap` holds `available$` open indefinitely
|
||||
takeUntil(anyComplete(id$)),
|
||||
);
|
||||
return policies$;
|
||||
}),
|
||||
map(
|
||||
(available) =>
|
||||
function (a: CredentialAlgorithm) {
|
||||
return isForwarderExtensionId(a) || available.has(a);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return available$;
|
||||
}
|
||||
|
||||
/** Retrieve credential algorithms filtered by the user's active policy.
|
||||
* @param requested when this has a `type` property, the method
|
||||
* returns all algorithms with a matching credential type. When this has an `algorithm`
|
||||
* property, the method returns 0 or 1 matching algorithms.
|
||||
* @param dependencies.account the account requesting algorithm access;
|
||||
* this parameter controls which policy, if any, is applied.
|
||||
* @returns an observable that emits matching algorithms. When no algorithms
|
||||
* match the request, an empty array is emitted.
|
||||
* @throws when neither `requested.algorithm` nor `requested.type` contains
|
||||
* a value.
|
||||
* @remarks this method applies policy controls. In particular, it excludes
|
||||
* algorithms prohibited by a policy control. If you want lists of algorithms
|
||||
* supported by the client, use `algorithms`.
|
||||
*/
|
||||
algorithms$(
|
||||
requested: AlgorithmRequest,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): Observable<CredentialAlgorithm[]>;
|
||||
algorithms$(
|
||||
requested: TypeRequest,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): Observable<CredentialAlgorithm[]>;
|
||||
algorithms$(
|
||||
requested: MetadataRequest,
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): Observable<CredentialAlgorithm[]> {
|
||||
if (isTypeRequest(requested)) {
|
||||
const { type } = requested;
|
||||
return this.isAvailable$(dependencies).pipe(
|
||||
map((isAvailable) => this.algorithms({ type }).filter(isAvailable)),
|
||||
);
|
||||
} else if (isAlgorithmRequest(requested)) {
|
||||
const { algorithm } = requested;
|
||||
return this.isAvailable$(dependencies).pipe(
|
||||
map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])),
|
||||
);
|
||||
} else {
|
||||
this.log.panic(requested, "algorithm or type required");
|
||||
}
|
||||
}
|
||||
|
||||
preference$(type: CredentialType, dependencies: BoundDependency<"account", Account>) {
|
||||
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||
|
||||
const algorithm$ = this.preferences({ account$ }).pipe(
|
||||
combineLatestWith(this.isAvailable$({ account$ })),
|
||||
map(([preferences, isAvailable]) => {
|
||||
const algorithm: CredentialAlgorithm = preferences[type].algorithm;
|
||||
if (isAvailable(algorithm)) {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
const algorithms = type ? this.algorithms({ type: type }) : [];
|
||||
// `?? null` because logging types must be `Jsonify<T>`
|
||||
const defaultAlgorithm = algorithms.find(isAvailable) ?? null;
|
||||
this.log.debug(
|
||||
{ algorithm, defaultAlgorithm, credentialType: type },
|
||||
"preference not available; defaulting the generator algorithm",
|
||||
);
|
||||
|
||||
// `?? undefined` so that interface is ADR-14 compliant
|
||||
return defaultAlgorithm ?? undefined;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
return algorithm$;
|
||||
}
|
||||
|
||||
/** Get a subject bound to credential generator preferences.
|
||||
* @param dependencies.account$ identifies the account to which the preferences are bound
|
||||
* @returns a subject bound to the user's preferences
|
||||
* @remarks Preferences determine which algorithms are used when generating a
|
||||
* credential from a credential type (e.g. `PassX` or `Username`). Preferences
|
||||
* should not be used to hold navigation history. Use @bitwarden/generator-navigation
|
||||
* instead.
|
||||
*/
|
||||
preferences(
|
||||
dependencies: BoundDependency<"account", Account>,
|
||||
): UserStateSubject<CredentialPreference> {
|
||||
// FIXME: enforce policy
|
||||
const subject = new UserStateSubject(PREFERENCES, this.system, dependencies);
|
||||
|
||||
return subject;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,9 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
|
||||
};
|
||||
/** Defines the stored parameters for credential generation */
|
||||
settings: {
|
||||
/** value used when an account's settings haven't been initialized */
|
||||
/** value used when an account's settings haven't been initialized
|
||||
* @deprecated use `ObjectKey.initial` for your desired storage property instead
|
||||
*/
|
||||
initial: Readonly<Partial<Settings>>;
|
||||
|
||||
/** Application-global constraints that apply to account settings */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { VendorId } from "@bitwarden/common/tools/extension";
|
||||
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||
|
||||
import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types";
|
||||
import { AlgorithmsByType, CredentialType } from "../metadata";
|
||||
|
||||
/** A type of password that may be generated by the credential generator. */
|
||||
export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number];
|
||||
@@ -11,7 +13,7 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number];
|
||||
/** A type of email address that may be generated by the credential generator. */
|
||||
export type EmailAlgorithm = (typeof EmailAlgorithms)[number];
|
||||
|
||||
export type ForwarderIntegration = { forwarder: IntegrationId };
|
||||
export type ForwarderIntegration = { forwarder: IntegrationId & VendorId };
|
||||
|
||||
/** Returns true when the input algorithm is a forwarder integration. */
|
||||
export function isForwarderIntegration(
|
||||
@@ -74,8 +76,8 @@ export type CredentialCategory = keyof typeof CredentialCategories;
|
||||
/** The kind of credential to generate using a compound configuration. */
|
||||
// FIXME: extend the preferences to include a preferred forwarder
|
||||
export type CredentialPreference = {
|
||||
[Key in CredentialCategory]: {
|
||||
algorithm: (typeof CredentialCategories)[Key][number];
|
||||
[Key in CredentialType & CredentialCategory]: {
|
||||
algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number];
|
||||
updated: Date;
|
||||
};
|
||||
};
|
||||
|
||||
13
libs/tools/generator/core/src/types/metadata-request.ts
Normal file
13
libs/tools/generator/core/src/types/metadata-request.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CredentialAlgorithm, CredentialType } from "../metadata";
|
||||
|
||||
export type AlgorithmRequest = { algorithm: CredentialAlgorithm };
|
||||
export type TypeRequest = { type: CredentialType };
|
||||
export type MetadataRequest = Partial<AlgorithmRequest & TypeRequest>;
|
||||
|
||||
export function isAlgorithmRequest(request: MetadataRequest): request is AlgorithmRequest {
|
||||
return !!request.algorithm;
|
||||
}
|
||||
|
||||
export function isTypeRequest(request: MetadataRequest): request is TypeRequest {
|
||||
return !!request.type;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-section [formGroup]="sendOptionsForm">
|
||||
<bit-section [formGroup]="sendOptionsForm" disableMargin>
|
||||
<bit-section-header>
|
||||
<h2 class="tw-mt-4" bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-section [formGroup]="sendDetailsForm">
|
||||
<bit-section [formGroup]="sendDetailsForm" disableMargin>
|
||||
<bit-section-header class="tw-mt-2">
|
||||
<h2 bitTypography="h6">{{ "sendDetails" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-section *ngIf="sends?.length > 0">
|
||||
<bit-section *ngIf="sends?.length > 0" disableMargin>
|
||||
<bit-section-header>
|
||||
<h2 class="tw-font-bold" bitTypography="h6">
|
||||
{{ headerText }}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<bit-section [formGroup]="additionalOptionsForm">
|
||||
<bit-section
|
||||
[formGroup]="additionalOptionsForm"
|
||||
[disableMargin]="disableSectionMargin && !hasCustomFields"
|
||||
>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "additionalOptions" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
@@ -29,4 +32,7 @@
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<vault-custom-fields (numberOfFieldsChange)="handleCustomFieldChange($event)"></vault-custom-fields>
|
||||
<vault-custom-fields
|
||||
(numberOfFieldsChange)="handleCustomFieldChange($event)"
|
||||
[disableSectionMargin]="disableSectionMargin"
|
||||
></vault-custom-fields>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
@@ -18,7 +18,9 @@ import { AdditionalOptionsSectionComponent } from "./additional-options-section.
|
||||
selector: "vault-custom-fields",
|
||||
template: "",
|
||||
})
|
||||
class MockCustomFieldsComponent {}
|
||||
class MockCustomFieldsComponent {
|
||||
@Input() disableSectionMargin: boolean;
|
||||
}
|
||||
|
||||
describe("AdditionalOptionsSectionComponent", () => {
|
||||
let component: AdditionalOptionsSectionComponent;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectorRef, Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { ChangeDetectorRef, Component, Input, OnInit, ViewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { shareReplay } from "rxjs";
|
||||
@@ -59,6 +59,8 @@ export class AdditionalOptionsSectionComponent implements OnInit {
|
||||
/** True when the form is in `partial-edit` mode */
|
||||
isPartialEdit = false;
|
||||
|
||||
@Input() disableSectionMargin: boolean;
|
||||
|
||||
constructor(
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private formBuilder: FormBuilder,
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
[originalCipherView]="originalCipherView"
|
||||
></vault-sshkey-section>
|
||||
|
||||
<vault-additional-options-section></vault-additional-options-section>
|
||||
<vault-additional-options-section
|
||||
[disableSectionMargin]="config.mode !== 'edit'"
|
||||
></vault-additional-options-section>
|
||||
|
||||
<!-- Attachments are only available for existing ciphers -->
|
||||
<ng-container *ngIf="config.mode == 'edit'">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-section *ngIf="hasCustomFields">
|
||||
<bit-section *ngIf="hasCustomFields" [disableMargin]="disableSectionMargin">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "customFields" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
QueryList,
|
||||
@@ -94,6 +95,8 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit {
|
||||
|
||||
@ViewChildren("customFieldRow") customFieldRows: QueryList<ElementRef<HTMLDivElement>>;
|
||||
|
||||
@Input() disableSectionMargin: boolean;
|
||||
|
||||
customFieldsForm = this.formBuilder.group({
|
||||
fields: new FormArray([]),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<bit-section>
|
||||
<bit-section disableMargin>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "itemHistory" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
@@ -34,13 +34,6 @@ export abstract class EndUserNotificationService {
|
||||
*/
|
||||
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create/update a notification in the state for the user specified within the notification.
|
||||
* @remarks This method should only be called when a notification payload is received from the web socket.
|
||||
* @param notification
|
||||
*/
|
||||
abstract upsert(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all notifications from state for the given user.
|
||||
* @param userId
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DefaultEndUserNotificationService } from "@bitwarden/vault";
|
||||
@@ -36,6 +37,12 @@ describe("End User Notification Center Service", () => {
|
||||
send: mockApiSend,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: {
|
||||
notifications$: of(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { map, Observable, switchMap } from "rxjs";
|
||||
import { concatMap, filter, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -14,12 +16,30 @@ import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
@Injectable()
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
) {}
|
||||
private defaultNotifications: NotificationsService,
|
||||
) {
|
||||
this.defaultNotifications.notifications$
|
||||
.pipe(
|
||||
filter(
|
||||
([notification]) =>
|
||||
notification.type === NotificationType.Notification ||
|
||||
notification.type === NotificationType.NotificationStatus,
|
||||
),
|
||||
concatMap(([notification, userId]) =>
|
||||
this.updateNotificationState(userId, [
|
||||
new NotificationViewData(notification.payload as NotificationViewResponse),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
@@ -58,8 +78,6 @@ export class DefaultEndUserNotificationService implements EndUserNotificationSer
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
upsert(notification: Notification): any {}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.updateNotificationState(userId, []);
|
||||
}
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -189,7 +189,7 @@
|
||||
},
|
||||
"apps/browser": {
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.3.1"
|
||||
"version": "2025.3.2"
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@bitwarden/cli",
|
||||
|
||||
Reference in New Issue
Block a user