diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 878171cd172..9aa6745faaf 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -40,7 +40,10 @@ jobs: base_uri: https://ast.checkmarx.net/ cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} + additional_params: | + --report-format sarif \ + --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ + --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index ac40bb315b2..e01e2c5c02b 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -1,4 +1,5 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; +import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum"; @@ -113,6 +114,7 @@ type NotificationBackgroundExtensionMessageHandlers = { bgGetEnableChangedPasswordPrompt: () => Promise; bgGetEnableAddedLoginPrompt: () => Promise; bgGetExcludedDomains: () => Promise; + bgGetActiveUserServerConfig: () => Promise; getWebVaultUrlForNotification: () => Promise; }; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 3b05cf57a91..fd15ea6e93a 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -6,6 +6,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; @@ -54,6 +55,7 @@ describe("NotificationBackground", () => { const environmentService = mock(); const logService = mock(); const themeStateService = mock(); + const configService = mock(); beforeEach(() => { notificationBackground = new NotificationBackground( @@ -68,6 +70,7 @@ describe("NotificationBackground", () => { environmentService, logService, themeStateService, + configService, ); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index c14531ee748..74e61475055 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -8,6 +8,8 @@ import { NOTIFICATION_BAR_LIFESPAN_MS } from "@bitwarden/common/autofill/constan import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -64,6 +66,7 @@ export default class NotificationBackground { bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(), bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(), bgGetExcludedDomains: () => this.getExcludedDomains(), + bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(), getWebVaultUrlForNotification: () => this.getWebVaultUrl(), }; @@ -79,6 +82,7 @@ export default class NotificationBackground { private environmentService: EnvironmentService, private logService: LogService, private themeStateService: ThemeStateService, + private configService: ConfigService, ) {} async init() { @@ -112,6 +116,13 @@ export default class NotificationBackground { return await firstValueFrom(this.domainSettingsService.neverDomains$); } + /** + * Gets the active user server config from the config service. + */ + async getActiveUserServerConfig(): Promise { + return await firstValueFrom(this.configService.serverConfig$); + } + /** * Checks the notification queue for any messages that need to be sent to the * specified tab. If no tab is specified, the current tab will be used. diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 1a0068d6419..65aa340b99f 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -848,8 +848,8 @@ class OverlayBackground implements OverlayBackgroundInterface { collectionIds: cipherView.collectionIds, }); - await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id }); + await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher"); } /** diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index d62e4857224..c948f7aa942 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -16,10 +16,6 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../../platform/background/service-factories/log-service.factory"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -44,7 +40,6 @@ type AutoFillServiceOptions = FactoryOptions; export type AutoFillServiceInitOptions = AutoFillServiceOptions & CipherServiceInitOptions & - StateServiceInitOptions & AutofillSettingsServiceInitOptions & TotpServiceInitOptions & EventCollectionServiceInitOptions & @@ -63,7 +58,6 @@ export function autofillServiceFactory( async () => new AutofillService( await cipherServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), await autofillSettingsServiceFactory(cache, opts), await totpServiceFactory(cache, opts), await eventCollectionServiceFactory(cache, opts), diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts index e5bd3003d6a..e71f6ca8b24 100644 --- a/apps/browser/src/autofill/content/autofill-init.ts +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -165,9 +165,7 @@ class AutofillInit implements AutofillInitInterface { return pageDetails; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - chrome.runtime.sendMessage({ + void chrome.runtime.sendMessage({ command: "collectPageDetailsResponse", tab: message.tab, details: pageDetails, diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index 8c1ef93c324..2bcf4394fd9 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -1,3 +1,4 @@ +import { ServerConfig } from "../../../../../libs/common/src/platform/abstractions/config/server-config"; import { AddLoginMessageData, ChangePasswordMessageData, @@ -6,12 +7,7 @@ import AutofillField from "../models/autofill-field"; import { WatchedForm } from "../models/watched-form"; import { NotificationBarIframeInitData } from "../notification/abstractions/notification-bar"; import { FormData } from "../services/abstractions/autofill.service"; -import { UserSettings } from "../types"; -import { - getFromLocalStorage, - sendExtensionMessage, - setupExtensionDisconnectAction, -} from "../utils"; +import { sendExtensionMessage, setupExtensionDisconnectAction } from "../utils"; interface HTMLElementWithFormOpId extends HTMLElement { formOpId: string; @@ -95,25 +91,17 @@ async function loadNotificationBar() { ); const enableAddedLoginPrompt = await sendExtensionMessage("bgGetEnableAddedLoginPrompt"); const excludedDomains = await sendExtensionMessage("bgGetExcludedDomains"); + const activeUserServerConfig: ServerConfig = await sendExtensionMessage( + "bgGetActiveUserServerConfig", + ); + const activeUserVault = activeUserServerConfig?.environment?.vault; let showNotificationBar = true; - // Look up the active user id from storage - const activeUserIdKey = "activeUserId"; - let activeUserId: string; - - const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey); - if (activeUserStorageValue[activeUserIdKey]) { - activeUserId = activeUserStorageValue[activeUserIdKey]; - } - - // Look up the user's settings from storage - const userSettingsStorageValue = await getFromLocalStorage(activeUserId); - if (userSettingsStorageValue[activeUserId]) { - const userSettings: UserSettings = userSettingsStorageValue[activeUserId].settings; + if (activeUserVault) { // Do not show the notification bar on the Bitwarden vault // because they can add logins and change passwords there - if (window.location.origin === userSettings.serverConfig.environment.vault) { + if (window.location.origin === activeUserVault) { showNotificationBar = false; } else { // NeverDomains is a dictionary of domains that the user has chosen to never diff --git a/apps/browser/src/autofill/overlay/pages/list/__snapshots__/autofill-overlay-list.spec.ts.snap b/apps/browser/src/autofill/overlay/pages/list/__snapshots__/autofill-overlay-list.spec.ts.snap index da9a0c53bf8..6ee8e737cb5 100644 --- a/apps/browser/src/autofill/overlay/pages/list/__snapshots__/autofill-overlay-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/pages/list/__snapshots__/autofill-overlay-list.spec.ts.snap @@ -2,9 +2,7 @@ exports[`AutofillOverlayList initAutofillOverlayList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
- {{ "additionalServiceAccounts" | i18n }} + {{ "additionalMachineAccounts" | i18n }} - {{ "includedServiceAccounts" | i18n: serviceAccountsIncluded }} + {{ "includedMachineAccounts" | i18n: serviceAccountsIncluded }} {{ - "addAdditionalServiceAccounts" | i18n: (monthlyCostPerServiceAccount | currency: "$") + "addAdditionalMachineAccounts" | i18n: (monthlyCostPerServiceAccount | currency: "$") }} diff --git a/apps/web/src/app/components/selectable-avatar.component.ts b/apps/web/src/app/components/selectable-avatar.component.ts index 4a138ec989a..1de722461a9 100644 --- a/apps/web/src/app/components/selectable-avatar.component.ts +++ b/apps/web/src/app/components/selectable-avatar.component.ts @@ -41,13 +41,13 @@ export class SelectableAvatarComponent { .concat(["tw-cursor-pointer", "tw-outline", "tw-outline-solid", "tw-outline-offset-1"]) .concat( this.selected - ? ["tw-outline-[3px]", "tw-outline-primary-500"] + ? ["tw-outline-[3px]", "tw-outline-primary-600"] : [ "tw-outline-0", "hover:tw-outline-1", "hover:tw-outline-primary-300", "focus:tw-outline-2", - "focus:tw-outline-primary-500", + "focus:tw-outline-primary-600", ], ); } diff --git a/apps/web/src/app/layouts/org-switcher/org-switcher.component.ts b/apps/web/src/app/layouts/org-switcher/org-switcher.component.ts index 2c44a045c70..fc77e79a127 100644 --- a/apps/web/src/app/layouts/org-switcher/org-switcher.component.ts +++ b/apps/web/src/app/layouts/org-switcher/org-switcher.component.ts @@ -46,7 +46,6 @@ export class OrgSwitcherComponent { /** * Visibility of the New Organization button - * (Temporary; will be removed when ability to create organizations is added to SM.) */ @Input() hideNewButton = false; diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html index 9068f9c0719..f038fafecc9 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html @@ -14,10 +14,10 @@ [routerLink]="product.appRoute" [ngClass]=" product.isActive - ? 'tw-bg-primary-500 tw-font-bold !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-500' + ? 'tw-bg-primary-600 tw-font-bold !tw-text-contrast tw-ring-offset-2 hover:tw-bg-primary-600' : '' " - class="tw-group tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-500 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700" + class="tw-group tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700" ariaCurrentWhenActive="page" > diff --git a/apps/web/src/app/layouts/toggle-width.component.ts b/apps/web/src/app/layouts/toggle-width.component.ts new file mode 100644 index 00000000000..0497416d624 --- /dev/null +++ b/apps/web/src/app/layouts/toggle-width.component.ts @@ -0,0 +1,33 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { NavigationModule } from "@bitwarden/components"; + +@Component({ + selector: "app-toggle-width", + template: ``, + standalone: true, + imports: [CommonModule, NavigationModule], +}) +export class ToggleWidthComponent { + protected isDev: boolean; + + constructor(platformUtilsService: PlatformUtilsService) { + this.isDev = platformUtilsService.isDev(); + } + + protected toggleWidth() { + if (document.body.style.minWidth === "unset") { + document.body.style.minWidth = ""; + } else { + document.body.style.minWidth = "unset"; + } + } +} diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 397e95d485b..15a01fa07bb 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -19,7 +19,7 @@ + + ; + protected showSubscription$: Observable; protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( FeatureFlag.ShowPaymentMethodWarningBanners, @@ -45,8 +44,6 @@ export class UserLayoutComponent implements OnInit, OnDestroy { ); constructor( - private broadcasterService: BroadcasterService, - private ngZone: NgZone, private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, private apiService: ApiService, @@ -58,43 +55,28 @@ export class UserLayoutComponent implements OnInit, OnDestroy { async ngOnInit() { document.body.classList.remove("layout_frontend"); - this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.ngZone.run(async () => { - switch (message.command) { - case "purchasedPremium": - await this.load(); - break; - default: - } - }); - }); - await this.syncService.fullSync(false); - await this.load(); - } - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - } + this.hasFamilySponsorshipAvailable$ = this.organizationService.canManageSponsorships$; - async load() { - const hasPremiumPersonally = await firstValueFrom( + // We want to hide the subscription menu for organizations that provide premium. + // Except if the user has premium personally or has a billing history. + this.showSubscription$ = combineLatest([ this.billingAccountProfileStateService.hasPremiumPersonally$, - ); - const hasPremiumFromOrg = await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$, - ); - const selfHosted = this.platformUtilsService.isSelfHost(); + ]).pipe( + concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => { + const isCloud = !this.platformUtilsService.isSelfHost(); - this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - let billing = null; - if (!selfHosted) { - // TODO: We should remove the need to call this! - billing = await this.apiService.getUserBillingHistory(); - } - this.hideSubscription = - !hasPremiumPersonally && hasPremiumFromOrg && (selfHosted || billing?.hasNoHistory); + let billing = null; + if (isCloud) { + // TODO: We should remove the need to call this! + billing = await this.apiService.getUserBillingHistory(); + } + + const cloudAndBillingHistory = isCloud && !billing?.hasNoHistory; + return hasPremiumPersonally || !hasPremiumFromOrg || cloudAndBillingHistory; + }), + ); } } diff --git a/apps/web/src/app/settings/low-kdf.component.html b/apps/web/src/app/settings/low-kdf.component.html index e140f345e9f..fd191b21e86 100644 --- a/apps/web/src/app/settings/low-kdf.component.html +++ b/apps/web/src/app/settings/low-kdf.component.html @@ -1,5 +1,5 @@ -
-
+
+
{{ "lowKdfIterations" | i18n }}
diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts deleted file mode 100644 index b5b198d0ac4..00000000000 --- a/apps/web/src/app/settings/settings.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -const BroadcasterSubscriptionId = "SettingsComponent"; - -@Component({ - selector: "app-settings", - templateUrl: "settings.component.html", -}) -export class SettingsComponent implements OnInit, OnDestroy { - premium: boolean; - selfHosted: boolean; - hasFamilySponsorshipAvailable: boolean; - hideSubscription: boolean; - - constructor( - private broadcasterService: BroadcasterService, - private ngZone: NgZone, - private platformUtilsService: PlatformUtilsService, - private organizationService: OrganizationService, - private apiService: ApiService, - private billingAccountProfileStateServiceAbstraction: BillingAccountProfileStateService, - ) {} - - async ngOnInit() { - this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.ngZone.run(async () => { - switch (message.command) { - case "purchasedPremium": - await this.load(); - break; - default: - } - }); - }); - - this.selfHosted = await this.platformUtilsService.isSelfHost(); - await this.load(); - } - - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - } - - async load() { - this.premium = await firstValueFrom( - this.billingAccountProfileStateServiceAbstraction.hasPremiumPersonally$, - ); - this.hasFamilySponsorshipAvailable = await this.organizationService.canManageSponsorships(); - const hasPremiumFromOrg = await firstValueFrom( - this.billingAccountProfileStateServiceAbstraction.hasPremiumFromAnyOrganization$, - ); - let billing = null; - if (!this.selfHosted) { - billing = await this.apiService.getUserBillingHistory(); - } - this.hideSubscription = - !this.premium && hasPremiumFromOrg && (this.selfHosted || billing?.hasNoHistory); - } -} diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.component.html b/apps/web/src/app/shared/components/onboarding/onboarding.component.html index 6a0bacbf89f..ecf1eb75dd1 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.component.html +++ b/apps/web/src/app/shared/components/onboarding/onboarding.component.html @@ -1,7 +1,7 @@
- +
{{ title }}
diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts index c1529dc81e8..4088b7335cd 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts @@ -39,7 +39,7 @@ const Template: Story = (args) => ({ template: ` diff --git a/apps/web/src/app/tools/send/icons/expired-send.icon.ts b/apps/web/src/app/tools/send/icons/expired-send.icon.ts index b39cdca797d..3ce0856a269 100644 --- a/apps/web/src/app/tools/send/icons/expired-send.icon.ts +++ b/apps/web/src/app/tools/send/icons/expired-send.icon.ts @@ -2,10 +2,10 @@ import { svgIcon } from "@bitwarden/components"; export const ExpiredSend = svgIcon` - - - - - + + + + + `; diff --git a/apps/web/src/app/tools/send/icons/no-send.icon.ts b/apps/web/src/app/tools/send/icons/no-send.icon.ts index 7811a4723bf..f5494a4b3c3 100644 --- a/apps/web/src/app/tools/send/icons/no-send.icon.ts +++ b/apps/web/src/app/tools/send/icons/no-send.icon.ts @@ -2,12 +2,12 @@ import { svgIcon } from "@bitwarden/components"; export const NoSend = svgIcon` - - - - - - - + + + + + + + `; diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 7a8e858ba57..8b6ead33bee 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -45,6 +45,7 @@ export class VaultItemsComponent { @Input() showBulkAddToCollections = false; @Input() showPermissionsColumn = false; @Input() viewingOrgVault: boolean; + @Input({ required: true }) flexibleCollectionsV1Enabled = false; private _ciphers?: CipherView[] = []; @Input() get ciphers(): CipherView[] { @@ -101,7 +102,7 @@ export class VaultItemsComponent { } const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); - return collection.canEdit(organization); + return collection.canEdit(organization, this.flexibleCollectionsV1Enabled); } protected canDeleteCollection(collection: CollectionView): boolean { diff --git a/apps/web/src/app/vault/core/views/collection-admin.view.ts b/apps/web/src/app/vault/core/views/collection-admin.view.ts index 160228576af..d942d42fb80 100644 --- a/apps/web/src/app/vault/core/views/collection-admin.view.ts +++ b/apps/web/src/app/vault/core/views/collection-admin.view.ts @@ -31,10 +31,11 @@ export class CollectionAdminView extends CollectionView { this.assigned = response.assigned; } - override canEdit(org: Organization): boolean { + override canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { return org?.flexibleCollections - ? org?.canEditAnyCollection || this.manage - : org?.canEditAnyCollection || (org?.canEditAssignedCollections && this.assigned); + ? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage + : org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || + (org?.canEditAssignedCollections && this.assigned); } override canDelete(org: Organization): boolean { diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.html b/apps/web/src/app/vault/individual-vault/add-edit.component.html index 85075acfdd2..db7a89003d1 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.html +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.html @@ -322,7 +322,7 @@ > - - - - {{ "keyConnectorTestSuccess" | i18n }} - + {{ "ssoIdentifier" | i18n }} + + + {{ "ssoIdentifierHintPartOne" | i18n }} + {{ "domainVerification" | i18n }} + +
+ + + {{ "memberDecryptionOption" | i18n }} + + + {{ "masterPass" | i18n }} + + + + + {{ "keyConnector" | i18n }} + + + + + + {{ "memberDecryptionKeyConnectorDescStart" | i18n }} + {{ "memberDecryptionKeyConnectorDescLink" | i18n }} + {{ "memberDecryptionKeyConnectorDescEnd" | i18n }} + + + + + + {{ "trustedDevices" | i18n }} + + + {{ "memberDecryptionOptionTdeDescriptionPartOne" | i18n }} + {{ + "memberDecryptionOptionTdeDescriptionLinkOne" | i18n + }} + {{ "memberDecryptionOptionTdeDescriptionPartTwo" | i18n }} + {{ + "memberDecryptionOptionTdeDescriptionLinkTwo" | i18n + }} + {{ "memberDecryptionOptionTdeDescriptionPartThree" | i18n }} + {{ + "memberDecryptionOptionTdeDescriptionLinkThree" | i18n + }} + {{ "memberDecryptionOptionTdeDescriptionPartFour" | i18n }} + + + + + + + + {{ "keyConnectorWarning" | i18n }} + + + + {{ "keyConnectorUrl" | i18n }} + + + + + + {{ "keyConnectorTestSuccess" | i18n }} + + + + + +
+ + + {{ "type" | i18n }} + + -
+ +
+
+

{{ "openIdConnectConfig" | i18n }}

- - {{ "type" | i18n }} - - - + + {{ "callbackPath" | i18n }} + + + - -
-
-

{{ "openIdConnectConfig" | i18n }}

+ + {{ "signedOutCallbackPath" | i18n }} + + + - - {{ "callbackPath" | i18n }} - - - + + {{ "authority" | i18n }} + + - - {{ "signedOutCallbackPath" | i18n }} - - - + + {{ "clientId" | i18n }} + + - - {{ "authority" | i18n }} - - + + {{ "clientSecret" | i18n }} + + - - {{ "clientId" | i18n }} - - + + {{ "metadataAddress" | i18n }} + + {{ "openIdAuthorityRequired" | i18n }} + - - {{ "clientSecret" | i18n }} - - + + {{ "oidcRedirectBehavior" | i18n }} + + - - {{ "metadataAddress" | i18n }} - - {{ "openIdAuthorityRequired" | i18n }} - + + {{ "getClaimsFromUserInfoEndpoint" | i18n }} + + - - {{ "oidcRedirectBehavior" | i18n }} - - - - - {{ "getClaimsFromUserInfoEndpoint" | i18n }} - - - - -
-

- {{ "openIdOptionalCustomizations" | i18n }} -

- -
-
- - {{ "additionalScopes" | i18n }} - - {{ "separateMultipleWithComma" | i18n }} - +

+ {{ "openIdOptionalCustomizations" | i18n }} +

+ +
+
+ + {{ "additionalScopes" | i18n }} + + {{ "separateMultipleWithComma" | i18n }} + - - {{ "additionalUserIdClaimTypes" | i18n }} - - {{ "separateMultipleWithComma" | i18n }} - + + {{ "additionalUserIdClaimTypes" | i18n }} + + {{ "separateMultipleWithComma" | i18n }} + - - {{ "additionalEmailClaimTypes" | i18n }} - - {{ "separateMultipleWithComma" | i18n }} - + + {{ "additionalEmailClaimTypes" | i18n }} + + {{ "separateMultipleWithComma" | i18n }} + - - {{ "additionalNameClaimTypes" | i18n }} - - {{ "separateMultipleWithComma" | i18n }} - + + {{ "additionalNameClaimTypes" | i18n }} + + {{ "separateMultipleWithComma" | i18n }} + - - {{ "acrValues" | i18n }} - - acr_values - + + {{ "acrValues" | i18n }} + + acr_values + - - {{ "expectedReturnAcrValue" | i18n }} - - acr_validaton - + + {{ "expectedReturnAcrValue" | i18n }} + + acr_validaton + +
-
- -
-
-

{{ "samlSpConfig" | i18n }}

+
+ +
+

{{ "samlSpConfig" | i18n }}

- - {{ "spUniqueEntityId" | i18n }} - - {{ "spUniqueEntityIdDesc" | i18n }} - + + {{ "spUniqueEntityId" | i18n }} + + {{ "spUniqueEntityIdDesc" | i18n }} + - - {{ "spEntityId" | i18n }} - - - + + {{ "spEntityId" | i18n }} + + + - - {{ "spEntityId" | i18n }} - - - + + {{ "spEntityId" | i18n }} + + + - - {{ "spMetadataUrl" | i18n }} - - - - - - - {{ "spAcsUrl" | i18n }} - - - - - - {{ "spNameIdFormat" | i18n }} - + + + - - {{ "spOutboundSigningAlgorithm" | i18n }} - - + + {{ "spAcsUrl" | i18n }} + + + - - {{ "spSigningBehavior" | i18n }} - - + + {{ "spNameIdFormat" | i18n }} + + - - {{ "spMinIncomingSigningAlgorithm" | i18n }} - - + + {{ "spOutboundSigningAlgorithm" | i18n }} + + - - {{ "spWantAssertionsSigned" | i18n }} - - + + {{ "spSigningBehavior" | i18n }} + + - - {{ "spValidateCertificates" | i18n }} - - + + {{ "spMinIncomingSigningAlgorithm" | i18n }} + + + + + {{ "spWantAssertionsSigned" | i18n }} + + + + + {{ "spValidateCertificates" | i18n }} + + +
+ + +
+

{{ "samlIdpConfig" | i18n }}

+ + + {{ "idpEntityId" | i18n }} + + + + + {{ "idpBindingType" | i18n }} + + + + + {{ "idpSingleSignOnServiceUrl" | i18n }} + + {{ "idpSingleSignOnServiceUrlRequired" | i18n }} + + + + {{ "idpSingleLogoutServiceUrl" | i18n }} + + + + + {{ "idpX509PublicCert" | i18n }} + + + + + {{ "idpOutboundSigningAlgorithm" | i18n }} + + + + + + + + {{ "idpAllowOutboundLogoutRequests" | i18n }} + + + + + {{ "idpSignAuthenticationRequests" | i18n }} + + +
- -
-

{{ "samlIdpConfig" | i18n }}

- - - {{ "idpEntityId" | i18n }} - - - - - {{ "idpBindingType" | i18n }} - - - - - {{ "idpSingleSignOnServiceUrl" | i18n }} - - {{ "idpSingleSignOnServiceUrlRequired" | i18n }} - - - - {{ "idpSingleLogoutServiceUrl" | i18n }} - - - - - {{ "idpX509PublicCert" | i18n }} - - - - - {{ "idpOutboundSigningAlgorithm" | i18n }} - - - - - - - - {{ "idpAllowOutboundLogoutRequests" | i18n }} - - - - - {{ "idpSignAuthenticationRequests" | i18n }} - - -
-
- - - - + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts index ee50efb3e49..19d8f53a348 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts @@ -2,13 +2,20 @@ import { NgModule } from "@angular/core"; import { LayoutComponent as BitLayoutComponent, NavigationModule } from "@bitwarden/components"; import { OrgSwitcherComponent } from "@bitwarden/web-vault/app/layouts/org-switcher/org-switcher.component"; +import { ToggleWidthComponent } from "@bitwarden/web-vault/app/layouts/toggle-width.component"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; import { LayoutComponent } from "./layout.component"; import { NavigationComponent } from "./navigation.component"; @NgModule({ - imports: [SharedModule, NavigationModule, BitLayoutComponent, OrgSwitcherComponent], + imports: [ + SharedModule, + NavigationModule, + BitLayoutComponent, + OrgSwitcherComponent, + ToggleWidthComponent, + ], declarations: [LayoutComponent, NavigationComponent], }) export class LayoutModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html index 51a163377d9..e71f520996e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -18,7 +18,7 @@ > @@ -41,4 +41,6 @@ [relativeTo]="route.parent" > + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index 04d705af237..255877e4e8d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -5,7 +5,7 @@

- {{ "projectServiceAccountsDescription" | i18n }} + {{ "projectMachineAccountsDescription" | i18n }}

{{ "secrets" | i18n }} {{ "people" | i18n }} - {{ "serviceAccounts" | i18n }} + {{ "machineAccounts" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html index 9af34837037..5ef2be8ade6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html @@ -8,7 +8,7 @@ {{ data.serviceAccounts.length }} - {{ "serviceAccounts" | i18n }} + {{ "machineAccounts" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts index 3d136aa92aa..b31ef03d12d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts @@ -43,14 +43,14 @@ export class ServiceAccountDeleteDialogComponent { get title() { return this.data.serviceAccounts.length === 1 - ? this.i18nService.t("deleteServiceAccount") - : this.i18nService.t("deleteServiceAccounts"); + ? this.i18nService.t("deleteMachineAccount") + : this.i18nService.t("deleteMachineAccounts"); } get dialogContent() { return this.data.serviceAccounts.length === 1 - ? this.i18nService.t("deleteServiceAccountDialogMessage", this.data.serviceAccounts[0].name) - : this.i18nService.t("deleteServiceAccountsDialogMessage"); + ? this.i18nService.t("deleteMachineAccountDialogMessage", this.data.serviceAccounts[0].name) + : this.i18nService.t("deleteMachineAccountsDialogMessage"); } get dialogConfirmationLabel() { @@ -79,17 +79,17 @@ export class ServiceAccountDeleteDialogComponent { const message = this.data.serviceAccounts.length === 1 - ? "deleteServiceAccountToast" - : "deleteServiceAccountsToast"; + ? "deleteMachineAccountToast" + : "deleteMachineAccountsToast"; this.platformUtilsService.showToast("success", null, this.i18nService.t(message)); } openBulkStatusDialog(bulkStatusResults: BulkOperationStatus[]) { this.dialogService.open(BulkStatusDialogComponent, { data: { - title: "deleteServiceAccounts", - subTitle: "serviceAccounts", - columnTitle: "serviceAccountName", + title: "deleteMachineAccounts", + subTitle: "machineAccounts", + columnTitle: "machineAccountName", message: "bulkDeleteProjectsErrorMessage", details: bulkStatusResults, }, @@ -100,7 +100,7 @@ export class ServiceAccountDeleteDialogComponent { return this.data.serviceAccounts?.length === 1 ? this.i18nService.t("deleteProjectConfirmMessage", this.data.serviceAccounts[0].name) : this.i18nService.t( - "deleteServiceAccountsConfirmMessage", + "deleteMachineAccountsConfirmMessage", this.data.serviceAccounts?.length.toString(), ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html index 55f6ff4da14..00643415374 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html @@ -7,7 +7,7 @@
- {{ "serviceAccountName" | i18n }} + {{ "machineAccountName" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 9aa7c658f30..105ca59e57f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -69,7 +69,7 @@ export class ServiceAccountDialogComponent { this.platformUtilsService.showToast( "error", null, - this.i18nService.t("serviceAccountsCannotCreate"), + this.i18nService.t("machineAccountsCannotCreate"), ); return; } @@ -85,14 +85,14 @@ export class ServiceAccountDialogComponent { if (this.data.operation == OperationType.Add) { await this.serviceAccountService.create(this.data.organizationId, serviceAccountView); - serviceAccountMessage = this.i18nService.t("serviceAccountCreated"); + serviceAccountMessage = this.i18nService.t("machineAccountCreated"); } else { await this.serviceAccountService.update( this.data.serviceAccountId, this.data.organizationId, serviceAccountView, ); - serviceAccountMessage = this.i18nService.t("serviceAccountUpdated"); + serviceAccountMessage = this.i18nService.t("machineAccountUpdated"); } this.platformUtilsService.showToast("success", null, serviceAccountMessage); @@ -107,6 +107,6 @@ export class ServiceAccountDialogComponent { } get title() { - return this.data.operation === OperationType.Add ? "newServiceAccount" : "editServiceAccount"; + return this.data.operation === OperationType.Add ? "newMachineAccount" : "editMachineAccount"; } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts index 1ef71811a16..554e7fa37d7 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts @@ -65,7 +65,7 @@ export class ServiceAccountEventsComponent extends BaseEventsComponent implement protected getUserName() { return { - name: this.i18nService.t("serviceAccount") + " " + this.serviceAccountId, + name: this.i18nService.t("machineAccount") + " " + this.serviceAccountId, email: "", }; } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html index 79c8132bbc3..074fa8ca004 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html @@ -1,7 +1,7 @@

- {{ "serviceAccountPeopleDescription" | i18n }} + {{ "machineAccountPeopleDescription" | i18n }}

{ const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "smAccessRemovalWarningSaTitle" }, - content: { key: "smAccessRemovalWarningSaMessage" }, + title: { key: "smAccessRemovalWarningMaTitle" }, + content: { key: "smAccessRemovalWarningMaMessage" }, acceptButtonText: { key: "removeAccess" }, cancelButtonText: { key: "cancel" }, type: "warning", @@ -222,7 +222,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy { private async showAccessTokenStillAvailableWarning(): Promise { await this.dialogService.openSimpleDialog({ title: { key: "saPeopleWarningTitle" }, - content: { key: "saPeopleWarningMessage" }, + content: { key: "maPeopleWarningMessage" }, type: "warning", acceptButtonText: { key: "close" }, cancelButtonText: null, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html index 368a62a9331..b97c5ef1141 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html @@ -1,6 +1,6 @@

- {{ "serviceAccountProjectsDescription" | i18n }} + {{ "machineAccountProjectsDescription" | i18n }}

{{ - "serviceAccounts" | i18n + "machineAccounts" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index d352e8a246b..083ec7aebb0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -49,7 +49,7 @@ export class ServiceAccountComponent implements OnInit, OnDestroy { this.platformUtilsService.showToast( "error", null, - this.i18nService.t("notFound", this.i18nService.t("serviceAccount")), + this.i18nService.t("notFound", this.i18nService.t("machineAccount")), ); }); return EMPTY; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html index fb8d953e107..bfb7b985423 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html @@ -3,8 +3,8 @@
- {{ "serviceAccountsNoItemsTitle" | i18n }} - {{ "serviceAccountsNoItemsMessage" | i18n }} + {{ "machineAccountsNoItemsTitle" | i18n }} + {{ "machineAccountsNoItemsMessage" | i18n }} @@ -80,16 +80,16 @@ - {{ "viewServiceAccount" | i18n }} + {{ "viewMachineAccount" | i18n }} @@ -101,7 +101,7 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html index 92ebcdbaac9..d7a4f2c747d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html @@ -1,6 +1,6 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html index 528514e678a..457eff37fac 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.html @@ -19,6 +19,6 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts index 0cad3129a40..f9ddcdad78d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts @@ -58,7 +58,7 @@ const routes: Routes = [ path: "service-accounts", loadChildren: () => ServiceAccountsModule, data: { - titleId: "serviceAccounts", + titleId: "machineAccounts", }, }, { diff --git a/libs/angular/src/auth/icons/create-passkey-failed.icon.ts b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts index 39a2389c5a9..65902a64c9f 100644 --- a/libs/angular/src/auth/icons/create-passkey-failed.icon.ts +++ b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts @@ -2,27 +2,27 @@ import { svgIcon } from "@bitwarden/components"; export const CreatePasskeyFailedIcon = svgIcon` - - - - - - - - - `; diff --git a/libs/angular/src/auth/icons/create-passkey.icon.ts b/libs/angular/src/auth/icons/create-passkey.icon.ts index c0e984bbee2..79ba4021b58 100644 --- a/libs/angular/src/auth/icons/create-passkey.icon.ts +++ b/libs/angular/src/auth/icons/create-passkey.icon.ts @@ -2,25 +2,25 @@ import { svgIcon } from "@bitwarden/components"; export const CreatePasskeyIcon = svgIcon` - - - - - - - - `; diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts index d1c82a37b36..d1857a88ad9 100644 --- a/libs/angular/src/tools/generator/components/generator.component.ts +++ b/libs/angular/src/tools/generator/components/generator.component.ts @@ -33,7 +33,7 @@ export class GeneratorComponent implements OnInit { subaddressOptions: any[]; catchallOptions: any[]; forwardOptions: EmailForwarderOptions[]; - usernameOptions: UsernameGeneratorOptions = {}; + usernameOptions: UsernameGeneratorOptions = { website: null }; passwordOptions: PasswordGeneratorOptions = {}; username = "-"; password = "-"; @@ -199,12 +199,12 @@ export class GeneratorComponent implements OnInit { } async sliderInput() { - this.normalizePasswordOptions(); + await this.normalizePasswordOptions(); this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); } async savePasswordOptions(regenerate = true) { - this.normalizePasswordOptions(); + await this.normalizePasswordOptions(); await this.passwordGenerationService.saveOptions(this.passwordOptions); if (regenerate && this.regenerateWithoutButtonPress()) { @@ -271,7 +271,7 @@ export class GeneratorComponent implements OnInit { return this.type !== "username" || this.usernameOptions.type !== "forwarded"; } - private normalizePasswordOptions() { + private async normalizePasswordOptions() { // Application level normalize options dependent on class variables this.passwordOptions.ambiguous = !this.avoidAmbiguous; @@ -290,9 +290,8 @@ export class GeneratorComponent implements OnInit { } } - this.passwordGenerationService.normalizeOptions( + await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions( this.passwordOptions, - this.enforcedPasswordPolicyOptions, ); this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 4c177a77f2f..6a0cfde350d 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -662,7 +662,7 @@ export class AddEditComponent implements OnInit, OnDestroy { // if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection if (!cipher.collectionIds) { - orgAdmin = this.organization?.canEditAnyCollection; + orgAdmin = this.organization?.canEditUnassignedCiphers(); } return this.cipher.id == null @@ -671,14 +671,14 @@ export class AddEditComponent implements OnInit, OnDestroy { } protected deleteCipher() { - const asAdmin = this.organization?.canEditAnyCollection; + const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, asAdmin) : this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin); } protected restoreCipher() { - const asAdmin = this.organization?.canEditAnyCollection; + const asAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); return this.cipherService.restoreWithServer(this.cipher.id, asAdmin); } diff --git a/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts b/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts index 1fb994fda1f..f661f9330b1 100644 --- a/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/auth/src/angular/icons/user-verification-biometrics-fingerprint.icon.ts @@ -2,11 +2,11 @@ import { svgIcon } from "@bitwarden/components"; export const UserVerificationBiometricsIcon = svgIcon` - - - - - - + + + + + + `; diff --git a/libs/auth/src/icons/bitwarden-logo.ts b/libs/auth/src/icons/bitwarden-logo.ts index 90591e0fe78..872228e75dd 100644 --- a/libs/auth/src/icons/bitwarden-logo.ts +++ b/libs/auth/src/icons/bitwarden-logo.ts @@ -3,7 +3,7 @@ import { svgIcon } from "@bitwarden/components"; export const BitwardenLogo = svgIcon` Bitwarden - - + + `; diff --git a/libs/auth/src/icons/icon-lock.ts b/libs/auth/src/icons/icon-lock.ts index b56c1ea36d7..61330fe0df5 100644 --- a/libs/auth/src/icons/icon-lock.ts +++ b/libs/auth/src/icons/icon-lock.ts @@ -2,6 +2,6 @@ import { svgIcon } from "@bitwarden/components"; export const IconLock = svgIcon` - + `; diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 9cc4bba0eb0..a1ae64a8858 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -116,7 +116,7 @@ export abstract class OrganizationService { * https://bitwarden.atlassian.net/browse/AC-2252. */ getFromState: (id: string) => Promise; - canManageSponsorships: () => Promise; + canManageSponsorships$: Observable; hasOrganizations: () => Promise; get$: (id: string) => Observable; get: (id: string) => Promise; diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index fb805f94cd9..21669f78ad2 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -78,5 +78,4 @@ export abstract class PolicyService { export abstract class InternalPolicyService extends PolicyService { upsert: (policy: PolicyData) => Promise; replace: (policies: { [id: string]: PolicyData }) => Promise; - clear: (userId?: string) => Promise; } diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 18b762207a1..bdf0b8fbbff 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -188,18 +188,30 @@ export class Organization { return this.isManager || this.permissions.createNewCollections; } - get canEditAnyCollection() { + canEditAnyCollection(flexibleCollectionsV1Enabled: boolean) { + if (!this.flexibleCollections || !flexibleCollectionsV1Enabled) { + // Pre-Flexible Collections v1 logic + return this.isAdmin || this.permissions.editAnyCollection; + } + + // Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins + // Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag + return ( + this.isProviderUser || + (this.type === OrganizationUserType.Custom && this.permissions.editAnyCollection) || + (this.allowAdminAccessToAllCollectionItems && this.isAdmin) + ); + } + + canEditUnassignedCiphers() { + // TODO: Update this to exclude Providers if provider access is restricted in AC-1707 return this.isAdmin || this.permissions.editAnyCollection; } - get canUseAdminCollections() { - return this.canEditAnyCollection; - } - canEditAllCiphers(flexibleCollectionsV1Enabled: boolean) { - // Before Flexible Collections, anyone with editAnyCollection permission could edit all ciphers - if (!flexibleCollectionsV1Enabled) { - return this.canEditAnyCollection; + // Before Flexible Collections, any admin or anyone with editAnyCollection permission could edit all ciphers + if (!this.flexibleCollections || !flexibleCollectionsV1Enabled) { + return this.isAdmin || this.permissions.editAnyCollection; } // Post Flexible Collections V1, the allowAdminAccessToAllCollectionItems flag can restrict admins // Providers and custom users with canEditAnyCollection are not affected by allowAdminAccessToAllCollectionItems flag @@ -214,8 +226,13 @@ export class Organization { return this.isAdmin || this.permissions.deleteAnyCollection; } + /** + * Whether the user can view all collection information, such as collection name and access. + * This does not indicate that the user can view items inside any collection - for that, see {@link canEditAllCiphers} + */ get canViewAllCollections() { - return this.canEditAnyCollection || this.canDeleteAnyCollection; + // Admins can always see all collections even if collection management settings prevent them from editing them or seeing items + return this.isAdmin || this.permissions.editAnyCollection || this.canDeleteAnyCollection; } /** diff --git a/libs/common/src/admin-console/services/organization/organization.service.spec.ts b/libs/common/src/admin-console/services/organization/organization.service.spec.ts index 908f4b8e28b..6d2525966bc 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.spec.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.spec.ts @@ -121,7 +121,7 @@ describe("OrganizationService", () => { const mockData: OrganizationData[] = buildMockOrganizations(1); mockData[0].familySponsorshipAvailable = true; fakeActiveUserState.nextState(arrayToRecord(mockData)); - const result = await organizationService.canManageSponsorships(); + const result = await firstValueFrom(organizationService.canManageSponsorships$); expect(result).toBe(true); }); @@ -129,7 +129,7 @@ describe("OrganizationService", () => { const mockData: OrganizationData[] = buildMockOrganizations(1); mockData[0].familySponsorshipFriendlyName = "Something"; fakeActiveUserState.nextState(arrayToRecord(mockData)); - const result = await organizationService.canManageSponsorships(); + const result = await firstValueFrom(organizationService.canManageSponsorships$); expect(result).toBe(true); }); @@ -137,7 +137,7 @@ describe("OrganizationService", () => { const mockData: OrganizationData[] = buildMockOrganizations(1); mockData[0].familySponsorshipFriendlyName = null; fakeActiveUserState.nextState(arrayToRecord(mockData)); - const result = await organizationService.canManageSponsorships(); + const result = await firstValueFrom(organizationService.canManageSponsorships$); expect(result).toBe(false); }); }); diff --git a/libs/common/src/admin-console/services/organization/organization.service.ts b/libs/common/src/admin-console/services/organization/organization.service.ts index 3c651f4660e..411850fe300 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.ts @@ -77,14 +77,10 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId)); } - async canManageSponsorships(): Promise { - return await firstValueFrom( - this.organizations$.pipe( - mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(), - mapToBooleanHasAnyOrganizations(), - ), - ); - } + canManageSponsorships$ = this.organizations$.pipe( + mapToExcludeOrganizationsWithoutFamilySponsorshipSupport(), + mapToBooleanHasAnyOrganizations(), + ); async hasOrganizations(): Promise { return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations())); diff --git a/libs/common/src/admin-console/services/policy/policy.service.spec.ts b/libs/common/src/admin-console/services/policy/policy.service.spec.ts index 8fa79f4d1c4..a1633d29ff2 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.spec.ts @@ -102,66 +102,6 @@ describe("PolicyService", () => { ]); }); - describe("clear", () => { - beforeEach(() => { - activeUserState.nextState( - arrayToRecord([ - policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { - minutes: 14, - }), - ]), - ); - }); - - it("clears state for the active user", async () => { - await policyService.clear(); - - expect(await firstValueFrom(policyService.policies$)).toEqual([]); - expect(await firstValueFrom(activeUserState.state$)).toEqual(null); - expect(stateProvider.activeUser.getFake(POLICIES).nextMock).toHaveBeenCalledWith([ - "userId", - null, - ]); - }); - - it("clears state for an inactive user", async () => { - const inactiveUserId = "someOtherUserId" as UserId; - const inactiveUserState = stateProvider.singleUser.getFake(inactiveUserId, POLICIES); - inactiveUserState.nextState( - arrayToRecord([ - policyData("10", "another-test-organization", PolicyType.PersonalOwnership, true), - ]), - ); - - await policyService.clear(inactiveUserId); - - // Active user is not affected - const expectedActiveUserPolicy: Partial = { - id: "1" as PolicyId, - organizationId: "test-organization", - type: PolicyType.MaximumVaultTimeout, - enabled: true, - data: { minutes: 14 }, - }; - expect(await firstValueFrom(policyService.policies$)).toEqual([expectedActiveUserPolicy]); - expect(await firstValueFrom(activeUserState.state$)).toEqual({ - "1": expectedActiveUserPolicy, - }); - expect(stateProvider.activeUser.getFake(POLICIES).nextMock).not.toHaveBeenCalled(); - - // Non-active user is cleared - expect( - await firstValueFrom( - policyService.getAll$(PolicyType.PersonalOwnership, "someOtherUserId" as UserId), - ), - ).toEqual([]); - expect(await firstValueFrom(inactiveUserState.state$)).toEqual(null); - expect( - stateProvider.singleUser.getFake("someOtherUserId" as UserId, POLICIES).nextMock, - ).toHaveBeenCalledWith(null); - }); - }); - describe("masterPasswordPolicyOptions", () => { it("returns default policy options", async () => { const data: any = { diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts index d60d2e32938..0cbc7204de0 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.ts @@ -1,6 +1,6 @@ import { combineLatest, firstValueFrom, map, Observable, of } from "rxjs"; -import { KeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state"; +import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state"; import { PolicyId, UserId } from "../../../types/guid"; import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction"; @@ -14,8 +14,9 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) => Object.values(policiesMap || {}).map((f) => new Policy(f)); -export const POLICIES = KeyDefinition.record(POLICIES_DISK, "policies", { +export const POLICIES = UserKeyDefinition.record(POLICIES_DISK, "policies", { deserializer: (policyData) => policyData, + clearOn: ["logout"], }); export class PolicyService implements InternalPolicyServiceAbstraction { @@ -222,10 +223,6 @@ export class PolicyService implements InternalPolicyServiceAbstraction { await this.activeUserPolicyState.update(() => policies); } - async clear(userId?: UserId): Promise { - await this.stateProvider.setUserState(POLICIES, null, userId); - } - /** * Determines whether an orgUser is exempt from a specific policy because of their role * Generally orgUsers who can manage policies are exempt from them, but some policies are stricter diff --git a/libs/common/src/admin-console/services/provider.service.ts b/libs/common/src/admin-console/services/provider.service.ts index 47291a5520e..064e0c7175b 100644 --- a/libs/common/src/admin-console/services/provider.service.ts +++ b/libs/common/src/admin-console/services/provider.service.ts @@ -1,13 +1,14 @@ import { Observable, map, firstValueFrom, of, switchMap, take } from "rxjs"; -import { KeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state"; +import { UserKeyDefinition, PROVIDERS_DISK, StateProvider } from "../../platform/state"; import { UserId } from "../../types/guid"; import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service"; import { ProviderData } from "../models/data/provider.data"; import { Provider } from "../models/domain/provider"; -export const PROVIDERS = KeyDefinition.record(PROVIDERS_DISK, "providers", { +export const PROVIDERS = UserKeyDefinition.record(PROVIDERS_DISK, "providers", { deserializer: (obj: ProviderData) => obj, + clearOn: ["logout"], }); function mapToSingleProvider(providerId: string) { diff --git a/libs/common/src/autofill/services/autofill-settings.service.ts b/libs/common/src/autofill/services/autofill-settings.service.ts index 49d6dc40de3..eb6191d10bf 100644 --- a/libs/common/src/autofill/services/autofill-settings.service.ts +++ b/libs/common/src/autofill/services/autofill-settings.service.ts @@ -9,40 +9,46 @@ import { GlobalState, KeyDefinition, StateProvider, + UserKeyDefinition, } from "../../platform/state"; import { ClearClipboardDelay, AutofillOverlayVisibility } from "../constants"; import { ClearClipboardDelaySetting, InlineMenuVisibilitySetting } from "../types"; -const AUTOFILL_ON_PAGE_LOAD = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autofillOnPageLoad", { +const AUTOFILL_ON_PAGE_LOAD = new UserKeyDefinition(AUTOFILL_SETTINGS_DISK, "autofillOnPageLoad", { deserializer: (value: boolean) => value ?? false, + clearOn: [], }); -const AUTOFILL_ON_PAGE_LOAD_DEFAULT = new KeyDefinition( +const AUTOFILL_ON_PAGE_LOAD_DEFAULT = new UserKeyDefinition( AUTOFILL_SETTINGS_DISK, "autofillOnPageLoadDefault", { deserializer: (value: boolean) => value ?? false, + clearOn: [], }, ); -const AUTOFILL_ON_PAGE_LOAD_CALLOUT_DISMISSED = new KeyDefinition( +const AUTOFILL_ON_PAGE_LOAD_CALLOUT_DISMISSED = new UserKeyDefinition( AUTOFILL_SETTINGS_DISK, "autofillOnPageLoadCalloutIsDismissed", { deserializer: (value: boolean) => value ?? false, + clearOn: [], }, ); -const AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED = new KeyDefinition( +const AUTOFILL_ON_PAGE_LOAD_POLICY_TOAST_HAS_DISPLAYED = new UserKeyDefinition( AUTOFILL_SETTINGS_DISK, "autofillOnPageLoadPolicyToastHasDisplayed", { deserializer: (value: boolean) => value ?? false, + clearOn: [], }, ); -const AUTO_COPY_TOTP = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", { +const AUTO_COPY_TOTP = new UserKeyDefinition(AUTOFILL_SETTINGS_DISK, "autoCopyTotp", { deserializer: (value: boolean) => value ?? true, + clearOn: [], }); const INLINE_MENU_VISIBILITY = new KeyDefinition( @@ -57,11 +63,12 @@ const ENABLE_CONTEXT_MENU = new KeyDefinition(AUTOFILL_SETTINGS_DISK, "enableCon deserializer: (value: boolean) => value ?? true, }); -const CLEAR_CLIPBOARD_DELAY = new KeyDefinition( +const CLEAR_CLIPBOARD_DELAY = new UserKeyDefinition( AUTOFILL_SETTINGS_DISK_LOCAL, "clearClipboardDelay", { deserializer: (value: ClearClipboardDelaySetting) => value ?? ClearClipboardDelay.Never, + clearOn: [], }, ); diff --git a/libs/common/src/autofill/services/badge-settings.service.ts b/libs/common/src/autofill/services/badge-settings.service.ts index dcd266f8856..e2f62b38b3f 100644 --- a/libs/common/src/autofill/services/badge-settings.service.ts +++ b/libs/common/src/autofill/services/badge-settings.service.ts @@ -3,12 +3,13 @@ import { map, Observable } from "rxjs"; import { BADGE_SETTINGS_DISK, ActiveUserState, - KeyDefinition, StateProvider, + UserKeyDefinition, } from "../../platform/state"; -const ENABLE_BADGE_COUNTER = new KeyDefinition(BADGE_SETTINGS_DISK, "enableBadgeCounter", { +const ENABLE_BADGE_COUNTER = new UserKeyDefinition(BADGE_SETTINGS_DISK, "enableBadgeCounter", { deserializer: (value: boolean) => value ?? true, + clearOn: [], }); export abstract class BadgeSettingsServiceAbstraction { diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index 6ef4d10c0a5..4b36e8d2bfc 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -29,11 +29,12 @@ const EQUIVALENT_DOMAINS = new UserKeyDefinition(DOMAIN_SETTINGS_DISK, "equivale clearOn: ["logout"], }); -const DEFAULT_URI_MATCH_STRATEGY = new KeyDefinition( +const DEFAULT_URI_MATCH_STRATEGY = new UserKeyDefinition( DOMAIN_SETTINGS_DISK, "defaultUriMatchStrategy", { deserializer: (value: UriMatchStrategySetting) => value ?? UriMatchStrategy.Domain, + clearOn: [], }, ); diff --git a/libs/common/src/billing/models/billing-keys.state.ts b/libs/common/src/billing/models/billing-keys.state.ts index 8367ff7fbeb..1d1cce6d0b2 100644 --- a/libs/common/src/billing/models/billing-keys.state.ts +++ b/libs/common/src/billing/models/billing-keys.state.ts @@ -1,7 +1,7 @@ -import { BILLING_DISK, KeyDefinition } from "../../platform/state"; +import { BILLING_DISK, UserKeyDefinition } from "../../platform/state"; import { PaymentMethodWarning } from "../models/domain/payment-method-warning"; -export const PAYMENT_METHOD_WARNINGS_KEY = KeyDefinition.record( +export const PAYMENT_METHOD_WARNINGS_KEY = UserKeyDefinition.record( BILLING_DISK, "paymentMethodWarnings", { @@ -9,5 +9,6 @@ export const PAYMENT_METHOD_WARNINGS_KEY = KeyDefinition.record( +export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new UserKeyDefinition( BILLING_DISK, "accountProfile", { deserializer: (billingAccountProfile) => billingAccountProfile, + clearOn: ["logout"], }, ); diff --git a/libs/common/src/platform/services/app-id.service.spec.ts b/libs/common/src/platform/services/app-id.service.spec.ts index ae44bc95e07..10fb153fdaa 100644 --- a/libs/common/src/platform/services/app-id.service.spec.ts +++ b/libs/common/src/platform/services/app-id.service.spec.ts @@ -14,7 +14,7 @@ describe("AppIdService", () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.restoreAllMocks(); }); describe("getAppId", () => { diff --git a/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts new file mode 100644 index 00000000000..e9fb7e0bb48 --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/generator-navigation.service.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { GeneratorNavigation } from "../navigation/generator-navigation"; +import { GeneratorNavigationPolicy } from "../navigation/generator-navigation-policy"; + +import { PolicyEvaluator } from "./policy-evaluator.abstraction"; + +/** Loads and stores generator navigational data + */ +export abstract class GeneratorNavigationService { + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + * @param userId: Identifies the user making the request + */ + options$: (userId: UserId) => Observable; + + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + * @param userId: Identifies the user making the request + */ + evaluator$: ( + userId: UserId, + ) => Observable>; + + /** Enforces the policy on the given options + * @param userId: Identifies the user making the request + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + enforcePolicy: (userId: UserId, options: GeneratorNavigation) => Promise; + + /** Saves the navigation options to disk. + * @param userId: Identifies the user making the request + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + saveOptions: (userId: UserId, options: GeneratorNavigation) => Promise; +} diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts index eda02f7cdcb..7cfe320abec 100644 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -17,6 +17,9 @@ export abstract class GeneratorStrategy { */ durableState: (userId: UserId) => SingleUserState; + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + /** Identifies the policy enforced by the generator. */ policy: PolicyType; diff --git a/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts index f1820ed707b..adb11655522 100644 --- a/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator.service.abstraction.ts @@ -21,6 +21,9 @@ export abstract class GeneratorService { */ evaluator$: (userId: UserId) => Observable>; + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + /** Enforces the policy on the given options * @param userId: Identifies the user making the request * @param options the options to enforce the policy on diff --git a/libs/common/src/tools/generator/abstractions/index.ts b/libs/common/src/tools/generator/abstractions/index.ts index 03285dd5ffe..13dce17d170 100644 --- a/libs/common/src/tools/generator/abstractions/index.ts +++ b/libs/common/src/tools/generator/abstractions/index.ts @@ -1,3 +1,4 @@ +export { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; export { PolicyEvaluator } from "./policy-evaluator.abstraction"; diff --git a/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts similarity index 69% rename from libs/common/src/tools/generator/password/password-generation.service.abstraction.ts rename to libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts index b8dac20972d..b3bd30be5c7 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts @@ -1,8 +1,8 @@ import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; +import { GeneratedPasswordHistory } from "../password/generated-password-history"; +import { PasswordGeneratorOptions } from "../password/password-generator-options"; -import { GeneratedPasswordHistory } from "./generated-password-history"; -import { PasswordGeneratorOptions } from "./password-generator-options"; - +/** @deprecated Use {@link GeneratorService} with a password or passphrase {@link GeneratorStrategy} instead. */ export abstract class PasswordGenerationServiceAbstraction { generatePassword: (options: PasswordGeneratorOptions) => Promise; generatePassphrase: (options: PasswordGeneratorOptions) => Promise; @@ -10,13 +10,8 @@ export abstract class PasswordGenerationServiceAbstraction { enforcePasswordGeneratorPoliciesOnOptions: ( options: PasswordGeneratorOptions, ) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; - getPasswordGeneratorPolicyOptions: () => Promise; saveOptions: (options: PasswordGeneratorOptions) => Promise; getHistory: () => Promise; addHistory: (password: string) => Promise; clear: (userId?: string) => Promise; - normalizeOptions: ( - options: PasswordGeneratorOptions, - enforcedPolicyOptions: PasswordGeneratorPolicyOptions, - ) => void; } diff --git a/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts similarity index 75% rename from libs/common/src/tools/generator/username/username-generation.service.abstraction.ts rename to libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts index 05affef0e2f..02b25e6113a 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts @@ -1,5 +1,6 @@ -import { UsernameGeneratorOptions } from "./username-generation-options"; +import { UsernameGeneratorOptions } from "../username/username-generation-options"; +/** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */ export abstract class UsernameGenerationServiceAbstraction { generateUsername: (options: UsernameGeneratorOptions) => Promise; generateWord: (options: UsernameGeneratorOptions) => Promise; diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts index 53a46c4963e..c93aec44d95 100644 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -37,6 +37,7 @@ function mockGeneratorStrategy(config?: { userState?: SingleUserState; policy?: PolicyType; evaluator?: any; + defaults?: any; }) { const durableState = config?.userState ?? new FakeSingleUserState(SomeUser); @@ -45,6 +46,7 @@ function mockGeneratorStrategy(config?: { // whether they're used properly are guaranteed to test // the value from `config`. durableState: jest.fn(() => durableState), + defaults$: jest.fn(() => new BehaviorSubject(config?.defaults)), policy: config?.policy ?? PolicyType.DisableSend, toEvaluator: jest.fn(() => pipe(map(() => config?.evaluator ?? mock>())), @@ -72,6 +74,20 @@ describe("Password generator service", () => { }); }); + describe("defaults$", () => { + it("should retrieve default state from the service", async () => { + const policy = mockPolicyService(); + const defaults = {}; + const strategy = mockGeneratorStrategy({ defaults }); + const service = new DefaultGeneratorService(strategy, policy); + + const result = await firstValueFrom(service.defaults$(SomeUser)); + + expect(strategy.defaults$).toHaveBeenCalledWith(SomeUser); + expect(result).toBe(defaults); + }); + }); + describe("saveOptions()", () => { it("should trigger an options$ update", async () => { const policy = mockPolicyService(); diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts index 34aacee695c..7fd794472c3 100644 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -21,17 +21,22 @@ export class DefaultGeneratorService implements GeneratorServic private _evaluators$ = new Map>>(); - /** {@link GeneratorService.options$()} */ + /** {@link GeneratorService.options$} */ options$(userId: UserId) { return this.strategy.durableState(userId).state$; } + /** {@link GeneratorService.defaults$} */ + defaults$(userId: UserId) { + return this.strategy.defaults$(userId); + } + /** {@link GeneratorService.saveOptions} */ async saveOptions(userId: UserId, options: Options): Promise { await this.strategy.durableState(userId).update(() => options); } - /** {@link GeneratorService.evaluator$()} */ + /** {@link GeneratorService.evaluator$} */ evaluator$(userId: UserId) { let evaluator$ = this._evaluators$.get(userId); @@ -59,7 +64,7 @@ export class DefaultGeneratorService implements GeneratorServic return evaluator$; } - /** {@link GeneratorService.enforcePolicy()} */ + /** {@link GeneratorService.enforcePolicy} */ async enforcePolicy(userId: UserId, options: Options): Promise { const policy = await firstValueFrom(this.evaluator$(userId)); const evaluated = policy.applyPolicy(options); diff --git a/libs/common/src/tools/generator/generator-options.ts b/libs/common/src/tools/generator/generator-options.ts index 4f8eb293ab5..d3d08025fae 100644 --- a/libs/common/src/tools/generator/generator-options.ts +++ b/libs/common/src/tools/generator/generator-options.ts @@ -1,3 +1,5 @@ -export type GeneratorOptions = { - type?: "password" | "username"; -}; +// this export provided solely for backwards compatibility +export { + /** @deprecated use `GeneratorNavigation` from './navigation' instead. */ + GeneratorNavigation as GeneratorOptions, +} from "./navigation/generator-navigation"; diff --git a/libs/common/src/tools/generator/generator-type.ts b/libs/common/src/tools/generator/generator-type.ts new file mode 100644 index 00000000000..f17eeb9c92b --- /dev/null +++ b/libs/common/src/tools/generator/generator-type.ts @@ -0,0 +1,2 @@ +/** The kind of credential being generated. */ +export type GeneratorType = "password" | "passphrase" | "username"; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts index f21767e77e8..9cbbc44e148 100644 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -10,9 +10,18 @@ import { FASTMAIL_FORWARDER, DUCK_DUCK_GO_FORWARDER, ADDY_IO_FORWARDER, + GENERATOR_SETTINGS, } from "./key-definitions"; describe("Key definitions", () => { + describe("GENERATOR_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = GENERATOR_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + describe("PASSWORD_SETTINGS", () => { it("should pass through deserialization", () => { const value = {}; @@ -31,7 +40,7 @@ describe("Key definitions", () => { describe("EFF_USERNAME_SETTINGS", () => { it("should pass through deserialization", () => { - const value = {}; + const value = { website: null as string }; const result = EFF_USERNAME_SETTINGS.deserializer(value); expect(result).toBe(value); }); @@ -39,7 +48,7 @@ describe("Key definitions", () => { describe("CATCHALL_SETTINGS", () => { it("should pass through deserialization", () => { - const value = {}; + const value = { website: null as string }; const result = CATCHALL_SETTINGS.deserializer(value); expect(result).toBe(value); }); @@ -47,7 +56,7 @@ describe("Key definitions", () => { describe("SUBADDRESS_SETTINGS", () => { it("should pass through deserialization", () => { - const value = {}; + const value = { website: null as string }; const result = SUBADDRESS_SETTINGS.deserializer(value); expect(result).toBe(value); }); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index d51af70f2e2..2f351696122 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,6 +1,7 @@ -import { GENERATOR_DISK, KeyDefinition } from "../../platform/state"; +import { GENERATOR_DISK, GENERATOR_MEMORY, KeyDefinition } from "../../platform/state"; import { GeneratedCredential } from "./history/generated-credential"; +import { GeneratorNavigation } from "./navigation/generator-navigation"; import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; import { PasswordGenerationOptions } from "./password/password-generation-options"; import { SecretClassifier } from "./state/secret-classifier"; @@ -15,6 +16,15 @@ import { } from "./username/options/forwarder-options"; import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; +/** plaintext password generation options */ +export const GENERATOR_SETTINGS = new KeyDefinition( + GENERATOR_MEMORY, + "generatorSettings", + { + deserializer: (value) => value, + }, +); + /** plaintext password generation options */ export const PASSWORD_SETTINGS = new KeyDefinition( GENERATOR_DISK, @@ -42,7 +52,7 @@ export const EFF_USERNAME_SETTINGS = new KeyDefinition( GENERATOR_DISK, "catchallGeneratorSettings", @@ -51,7 +61,7 @@ export const CATCHALL_SETTINGS = new KeyDefinition( }, ); -/** email subaddress generation options */ +/** plaintext configuration for an email subaddress. */ export const SUBADDRESS_SETTINGS = new KeyDefinition( GENERATOR_DISK, "subaddressGeneratorSettings", @@ -60,6 +70,7 @@ export const SUBADDRESS_SETTINGS = new KeyDefinition( GENERATOR_DISK, "addyIoForwarder", @@ -68,6 +79,7 @@ export const ADDY_IO_FORWARDER = new KeyDefinition( GENERATOR_DISK, "duckDuckGoForwarder", @@ -76,6 +88,7 @@ export const DUCK_DUCK_GO_FORWARDER = new KeyDefinition( }, ); +/** backing store configuration for {@link Forwarders.FastMail} */ export const FASTMAIL_FORWARDER = new KeyDefinition( GENERATOR_DISK, "fastmailForwarder", @@ -84,6 +97,7 @@ export const FASTMAIL_FORWARDER = new KeyDefinition( GENERATOR_DISK, "firefoxRelayForwarder", @@ -92,6 +106,7 @@ export const FIREFOX_RELAY_FORWARDER = new KeyDefinition( }, ); +/** backing store configuration for {@link Forwarders.ForwardEmail} */ export const FORWARD_EMAIL_FORWARDER = new KeyDefinition( GENERATOR_DISK, "forwardEmailForwarder", @@ -100,6 +115,7 @@ export const FORWARD_EMAIL_FORWARDER = new KeyDefinition( GENERATOR_DISK, "simpleLoginForwarder", diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts new file mode 100644 index 00000000000..093c68b3e83 --- /dev/null +++ b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts @@ -0,0 +1,470 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../shared/test.environment.ts + */ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { mockAccountServiceWith } from "../../../spec"; +import { UserId } from "../../types/guid"; + +import { GeneratorNavigationService, GeneratorService } from "./abstractions"; +import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; +import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; +import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; +import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; +import { + DefaultPassphraseGenerationOptions, + PassphraseGenerationOptions, + PassphraseGeneratorOptionsEvaluator, + PassphraseGeneratorPolicy, +} from "./passphrase"; +import { DisabledPassphraseGeneratorPolicy } from "./passphrase/passphrase-generator-policy"; +import { + DefaultPasswordGenerationOptions, + PasswordGenerationOptions, + PasswordGeneratorOptions, + PasswordGeneratorOptionsEvaluator, + PasswordGeneratorPolicy, +} from "./password"; +import { DisabledPasswordGeneratorPolicy } from "./password/password-generator-policy"; + +const SomeUser = "some user" as UserId; + +function createPassphraseGenerator( + options: PassphraseGenerationOptions = {}, + policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy, +) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new PassphraseGeneratorOptionsEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultPassphraseGenerationOptions); + }, + saveOptions(userId, options) { + savedOptions = options; + return Promise.resolve(); + }, + }); + + return generator; +} + +function createPasswordGenerator( + options: PasswordGenerationOptions = {}, + policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy, +) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new PasswordGeneratorOptionsEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultPasswordGenerationOptions); + }, + saveOptions(userId, options) { + savedOptions = options; + return Promise.resolve(); + }, + }); + + return generator; +} + +function createNavigationGenerator( + options: GeneratorNavigation = {}, + policy: GeneratorNavigationPolicy = {}, +) { + let savedOptions = options; + const generator = mock({ + evaluator$(id: UserId) { + const evaluator = new GeneratorNavigationEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultGeneratorNavigation); + }, + saveOptions(userId, options) { + savedOptions = options; + return Promise.resolve(); + }, + }); + + return generator; +} + +describe("LegacyPasswordGenerationService", () => { + // NOTE: in all tests, `null` constructor arguments are not used by the test. + // They're set to `null` to avoid setting up unnecessary mocks. + + describe("generatePassword", () => { + it("invokes the inner password generator to generate passwords", async () => { + const innerPassword = createPasswordGenerator(); + const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null); + const options = { type: "password" } as PasswordGeneratorOptions; + + await generator.generatePassword(options); + + expect(innerPassword.generate).toHaveBeenCalledWith(options); + }); + + it("invokes the inner passphrase generator to generate passphrases", async () => { + const innerPassphrase = createPassphraseGenerator(); + const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const options = { type: "passphrase" } as PasswordGeneratorOptions; + + await generator.generatePassword(options); + + expect(innerPassphrase.generate).toHaveBeenCalledWith(options); + }); + }); + + describe("generatePassphrase", () => { + it("invokes the inner passphrase generator", async () => { + const innerPassphrase = createPassphraseGenerator(); + const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const options = {} as PasswordGeneratorOptions; + + await generator.generatePassphrase(options); + + expect(innerPassphrase.generate).toHaveBeenCalledWith(options); + }); + }); + + describe("getOptions", () => { + it("combines options from its inner services", async () => { + const innerPassword = createPasswordGenerator({ + length: 29, + minLength: 20, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 2, + number: true, + minNumber: 3, + special: false, + minSpecial: 4, + }); + const innerPassphrase = createPassphraseGenerator({ + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }); + const navigation = createNavigationGenerator({ + type: "passphrase", + username: "word", + forwarder: "simplelogin", + }); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [result] = await generator.getOptions(); + + expect(result).toEqual({ + type: "passphrase", + username: "word", + forwarder: "simplelogin", + length: 29, + minLength: 20, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 2, + number: true, + minNumber: 3, + special: false, + minSpecial: 4, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }); + }); + + it("sets default options when an inner service lacks a value", async () => { + const innerPassword = createPasswordGenerator(null); + const innerPassphrase = createPassphraseGenerator(null); + const navigation = createNavigationGenerator(null); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [result] = await generator.getOptions(); + + expect(result).toEqual({ + ...DefaultGeneratorNavigation, + ...DefaultPassphraseGenerationOptions, + ...DefaultPasswordGenerationOptions, + }); + }); + + it("combines policies from its inner services", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + }, + ); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator( + {}, + { + defaultType: "password", + }, + ); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [, policy] = await generator.getOptions(); + + expect(policy).toEqual({ + defaultType: "password", + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }); + }); + }); + + describe("enforcePasswordGeneratorPoliciesOnOptions", () => { + it("returns its options parameter with password policy applied", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 15, + numberCount: 5, + specialCount: 5, + useUppercase: true, + useLowercase: true, + useNumbers: true, + useSpecial: true, + }, + ); + const innerPassphrase = createPassphraseGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(); + const options = { + type: "password" as const, + }; + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); + + expect(result).toBe(options); + expect(result).toMatchObject({ + length: 15, + minLength: 15, + minLowercase: 1, + minNumber: 5, + minUppercase: 1, + minSpecial: 5, + uppercase: true, + lowercase: true, + number: true, + special: true, + }); + }); + + it("returns its options parameter with passphrase policy applied", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: true, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(); + const options = { + type: "passphrase" as const, + }; + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); + + expect(result).toBe(options); + expect(result).toMatchObject({ + numWords: 5, + capitalize: true, + includeNumber: true, + }); + }); + + it("returns the applied policy", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + }, + ); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator( + {}, + { + defaultType: "password", + }, + ); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + + const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); + + expect(policy).toEqual({ + defaultType: "password", + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }); + }); + }); + + describe("saveOptions", () => { + it("loads saved password options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + const options = { + type: "password" as const, + username: "word" as const, + forwarder: "simplelogin" as const, + length: 29, + minLength: 20, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 2, + number: true, + minNumber: 3, + special: false, + minSpecial: 4, + }; + await generator.saveOptions(options); + + const [result] = await generator.getOptions(); + + expect(result).toMatchObject(options); + }); + + it("loads saved passphrase options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + ); + const options = { + type: "passphrase" as const, + username: "word" as const, + forwarder: "simplelogin" as const, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }; + await generator.saveOptions(options); + + const [result] = await generator.getOptions(); + + expect(result).toMatchObject(options); + }); + }); +}); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.ts b/libs/common/src/tools/generator/legacy-password-generation.service.ts new file mode 100644 index 00000000000..0b429b356bc --- /dev/null +++ b/libs/common/src/tools/generator/legacy-password-generation.service.ts @@ -0,0 +1,184 @@ +import { concatMap, zip, map, firstValueFrom } from "rxjs"; + +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { PasswordGeneratorPolicyOptions } from "../../admin-console/models/domain/password-generator-policy-options"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { StateProvider } from "../../platform/state"; + +import { GeneratorService, GeneratorNavigationService } from "./abstractions"; +import { PasswordGenerationServiceAbstraction } from "./abstractions/password-generation.service.abstraction"; +import { DefaultGeneratorService } from "./default-generator.service"; +import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; +import { + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PassphraseGeneratorStrategy, +} from "./passphrase"; +import { + PasswordGenerationOptions, + PasswordGenerationService, + PasswordGeneratorOptions, + PasswordGeneratorPolicy, + PasswordGeneratorStrategy, +} from "./password"; + +export function legacyPasswordGenerationServiceFactory( + cryptoService: CryptoService, + policyService: PolicyService, + accountService: AccountService, + stateProvider: StateProvider, +): PasswordGenerationServiceAbstraction { + // FIXME: Once the password generation service is replaced with this service + // in the clients, factor out the deprecated service in its entirety. + const deprecatedService = new PasswordGenerationService(cryptoService, null, null); + + const passwords = new DefaultGeneratorService( + new PasswordGeneratorStrategy(deprecatedService, stateProvider), + policyService, + ); + + const passphrases = new DefaultGeneratorService( + new PassphraseGeneratorStrategy(deprecatedService, stateProvider), + policyService, + ); + + const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); + + return new LegacyPasswordGenerationService(accountService, navigation, passwords, passphrases); +} + +/** Adapts the generator 2.0 design to 1.0 angular services. */ +export class LegacyPasswordGenerationService implements PasswordGenerationServiceAbstraction { + constructor( + private readonly accountService: AccountService, + private readonly navigation: GeneratorNavigationService, + private readonly passwords: GeneratorService< + PasswordGenerationOptions, + PasswordGeneratorPolicy + >, + private readonly passphrases: GeneratorService< + PassphraseGenerationOptions, + PassphraseGeneratorPolicy + >, + ) {} + + generatePassword(options: PasswordGeneratorOptions) { + if (options.type === "password") { + return this.passwords.generate(options); + } else { + return this.passphrases.generate(options); + } + } + + generatePassphrase(options: PasswordGeneratorOptions) { + return this.passphrases.generate(options); + } + + async getOptions() { + const options$ = this.accountService.activeAccount$.pipe( + concatMap((activeUser) => + zip( + this.passwords.options$(activeUser.id), + this.passwords.defaults$(activeUser.id), + this.passwords.evaluator$(activeUser.id), + this.passphrases.options$(activeUser.id), + this.passphrases.defaults$(activeUser.id), + this.passphrases.evaluator$(activeUser.id), + this.navigation.options$(activeUser.id), + this.navigation.defaults$(activeUser.id), + this.navigation.evaluator$(activeUser.id), + ), + ), + map( + ([ + passwordOptions, + passwordDefaults, + passwordEvaluator, + passphraseOptions, + passphraseDefaults, + passphraseEvaluator, + generatorOptions, + generatorDefaults, + generatorEvaluator, + ]) => { + const options: PasswordGeneratorOptions = Object.assign( + {}, + passwordOptions ?? passwordDefaults, + passphraseOptions ?? passphraseDefaults, + generatorOptions ?? generatorDefaults, + ); + + const policy = Object.assign( + new PasswordGeneratorPolicyOptions(), + passwordEvaluator.policy, + passphraseEvaluator.policy, + generatorEvaluator.policy, + ); + + return [options, policy] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + }, + ), + ); + + const options = await firstValueFrom(options$); + return options; + } + + async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) { + const options$ = this.accountService.activeAccount$.pipe( + concatMap((activeUser) => + zip( + this.passwords.evaluator$(activeUser.id), + this.passphrases.evaluator$(activeUser.id), + this.navigation.evaluator$(activeUser.id), + ), + ), + map(([passwordEvaluator, passphraseEvaluator, navigationEvaluator]) => { + const policy = Object.assign( + new PasswordGeneratorPolicyOptions(), + passwordEvaluator.policy, + passphraseEvaluator.policy, + navigationEvaluator.policy, + ); + + const navigationApplied = navigationEvaluator.applyPolicy(options); + const navigationSanitized = { + ...options, + ...navigationEvaluator.sanitize(navigationApplied), + }; + if (options.type === "password") { + const applied = passwordEvaluator.applyPolicy(navigationSanitized); + const sanitized = passwordEvaluator.sanitize(applied); + return [sanitized, policy]; + } else { + const applied = passphraseEvaluator.applyPolicy(navigationSanitized); + const sanitized = passphraseEvaluator.sanitize(applied); + return [sanitized, policy]; + } + }), + ); + + const [sanitized, policy] = await firstValueFrom(options$); + return [ + // callers assume this function updates the options parameter + Object.assign(options, sanitized), + policy, + ] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + } + + async saveOptions(options: PasswordGeneratorOptions) { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + await this.navigation.saveOptions(activeAccount.id, options); + if (options.type === "password") { + await this.passwords.saveOptions(activeAccount.id, options); + } else { + await this.passphrases.saveOptions(activeAccount.id, options); + } + } + + getHistory: () => Promise; + addHistory: (password: string) => Promise; + clear: (userId?: string) => Promise; +} diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts new file mode 100644 index 00000000000..41d9c78dd2b --- /dev/null +++ b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts @@ -0,0 +1,748 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { mockAccountServiceWith } from "../../../spec"; +import { UserId } from "../../types/guid"; + +import { GeneratorNavigationService, GeneratorService } from "./abstractions"; +import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; +import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; +import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; +import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; +import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; +import { NoPolicy } from "./no-policy"; +import { UsernameGeneratorOptions } from "./username"; +import { + CatchallGenerationOptions, + DefaultCatchallOptions, +} from "./username/catchall-generator-options"; +import { + DefaultEffUsernameOptions, + EffUsernameGenerationOptions, +} from "./username/eff-username-generator-options"; +import { DefaultAddyIoOptions } from "./username/forwarders/addy-io"; +import { DefaultDuckDuckGoOptions } from "./username/forwarders/duck-duck-go"; +import { DefaultFastmailOptions } from "./username/forwarders/fastmail"; +import { DefaultFirefoxRelayOptions } from "./username/forwarders/firefox-relay"; +import { DefaultForwardEmailOptions } from "./username/forwarders/forward-email"; +import { DefaultSimpleLoginOptions } from "./username/forwarders/simple-login"; +import { Forwarders } from "./username/options/constants"; +import { + ApiOptions, + EmailDomainOptions, + EmailPrefixOptions, + SelfHostedApiOptions, +} from "./username/options/forwarder-options"; +import { + DefaultSubaddressOptions, + SubaddressGenerationOptions, +} from "./username/subaddress-generator-options"; + +const SomeUser = "userId" as UserId; + +function createGenerator(options: Options, defaults: Options) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new DefaultPolicyEvaluator(); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(defaults); + }, + saveOptions: jest.fn((userId, options) => { + savedOptions = options; + return Promise.resolve(); + }), + }); + + return generator; +} + +function createNavigationGenerator( + options: GeneratorNavigation = {}, + policy: GeneratorNavigationPolicy = {}, +) { + let savedOptions = options; + const generator = mock({ + evaluator$(id: UserId) { + const evaluator = new GeneratorNavigationEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultGeneratorNavigation); + }, + saveOptions: jest.fn((userId, options) => { + savedOptions = options; + return Promise.resolve(); + }), + }); + + return generator; +} + +describe("LegacyUsernameGenerationService", () => { + // NOTE: in all tests, `null` constructor arguments are not used by the test. + // They're set to `null` to avoid setting up unnecessary mocks. + describe("generateUserName", () => { + it("should generate a catchall username", async () => { + const options = { type: "catchall" } as UsernameGeneratorOptions; + const catchall = createGenerator(null, null); + catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + catchall, + null, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(catchall.generate).toHaveBeenCalledWith(options); + expect(result).toBe("catchall@example.com"); + }); + + it("should generate an EFF word username", async () => { + const options = { type: "word" } as UsernameGeneratorOptions; + const effWord = createGenerator(null, null); + effWord.generate.mockReturnValue(Promise.resolve("eff word")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + effWord, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(effWord.generate).toHaveBeenCalledWith(options); + expect(result).toBe("eff word"); + }); + + it("should generate a subaddress username", async () => { + const options = { type: "subaddress" } as UsernameGeneratorOptions; + const subaddress = createGenerator(null, null); + subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + subaddress, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(subaddress.generate).toHaveBeenCalledWith(options); + expect(result).toBe("subaddress@example.com"); + }); + + it("should generate a forwarder username", async () => { + // set up an arbitrary forwarder for the username test; all forwarders tested in their own tests + const options = { + type: "forwarded", + forwardedService: Forwarders.AddyIo.id, + } as UsernameGeneratorOptions; + const addyIo = createGenerator(null, null); + addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + addyIo, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(addyIo.generate).toHaveBeenCalledWith({}); + expect(result).toBe("addyio@example.com"); + }); + }); + + describe("generateCatchall", () => { + it("should generate a catchall username", async () => { + const options = { type: "catchall" } as UsernameGeneratorOptions; + const catchall = createGenerator(null, null); + catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + catchall, + null, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateCatchall(options); + + expect(catchall.generate).toHaveBeenCalledWith(options); + expect(result).toBe("catchall@example.com"); + }); + }); + + describe("generateSubaddress", () => { + it("should generate a subaddress username", async () => { + const options = { type: "subaddress" } as UsernameGeneratorOptions; + const subaddress = createGenerator(null, null); + subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + subaddress, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateSubaddress(options); + + expect(subaddress.generate).toHaveBeenCalledWith(options); + expect(result).toBe("subaddress@example.com"); + }); + }); + + describe("generateForwarded", () => { + it("should generate a AddyIo username", async () => { + const options = { + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "token", + forwardedAnonAddyBaseUrl: "https://example.com", + forwardedAnonAddyDomain: "example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const addyIo = createGenerator(null, null); + addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + addyIo, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(addyIo.generate).toHaveBeenCalledWith({ + token: "token", + baseUrl: "https://example.com", + domain: "example.com", + website: "example.com", + }); + expect(result).toBe("addyio@example.com"); + }); + + it("should generate a DuckDuckGo username", async () => { + const options = { + forwardedService: Forwarders.DuckDuckGo.id, + forwardedDuckDuckGoToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const duckDuckGo = createGenerator(null, null); + duckDuckGo.generate.mockReturnValue(Promise.resolve("ddg@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + duckDuckGo, + null, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(duckDuckGo.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("ddg@example.com"); + }); + + it("should generate a Fastmail username", async () => { + const options = { + forwardedService: Forwarders.Fastmail.id, + forwardedFastmailApiToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const fastmail = createGenerator(null, null); + fastmail.generate.mockReturnValue(Promise.resolve("fastmail@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + fastmail, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(fastmail.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("fastmail@example.com"); + }); + + it("should generate a FirefoxRelay username", async () => { + const options = { + forwardedService: Forwarders.FirefoxRelay.id, + forwardedFirefoxApiToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const firefoxRelay = createGenerator(null, null); + firefoxRelay.generate.mockReturnValue(Promise.resolve("firefoxrelay@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + firefoxRelay, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(firefoxRelay.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("firefoxrelay@example.com"); + }); + + it("should generate a ForwardEmail username", async () => { + const options = { + forwardedService: Forwarders.ForwardEmail.id, + forwardedForwardEmailApiToken: "token", + forwardedForwardEmailDomain: "example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const forwardEmail = createGenerator(null, null); + forwardEmail.generate.mockReturnValue(Promise.resolve("forwardemail@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + forwardEmail, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(forwardEmail.generate).toHaveBeenCalledWith({ + token: "token", + domain: "example.com", + website: "example.com", + }); + expect(result).toBe("forwardemail@example.com"); + }); + + it("should generate a SimpleLogin username", async () => { + const options = { + forwardedService: Forwarders.SimpleLogin.id, + forwardedSimpleLoginApiKey: "token", + forwardedSimpleLoginBaseUrl: "https://example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const simpleLogin = createGenerator(null, null); + simpleLogin.generate.mockReturnValue(Promise.resolve("simplelogin@example.com")); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + simpleLogin, + ); + + const result = await generator.generateForwarded(options); + + expect(simpleLogin.generate).toHaveBeenCalledWith({ + token: "token", + baseUrl: "https://example.com", + website: "example.com", + }); + expect(result).toBe("simplelogin@example.com"); + }); + }); + + describe("getOptions", () => { + it("combines options from its inner generators", async () => { + const account = mockAccountServiceWith(SomeUser); + + const navigation = createNavigationGenerator({ + type: "username", + username: "catchall", + forwarder: Forwarders.AddyIo.id, + }); + + const catchall = createGenerator( + { + catchallDomain: "example.com", + catchallType: "random", + website: null, + }, + null, + ); + + const effUsername = createGenerator( + { + wordCapitalize: true, + wordIncludeNumber: false, + website: null, + }, + null, + ); + + const subaddress = createGenerator( + { + subaddressType: "random", + subaddressEmail: "foo@example.com", + website: null, + }, + null, + ); + + const addyIo = createGenerator( + { + token: "addyIoToken", + domain: "addyio.example.com", + baseUrl: "https://addyio.api.example.com", + website: null, + }, + null, + ); + + const duckDuckGo = createGenerator( + { + token: "ddgToken", + website: null, + }, + null, + ); + + const fastmail = createGenerator( + { + token: "fastmailToken", + domain: "fastmail.example.com", + prefix: "foo", + website: null, + }, + null, + ); + + const firefoxRelay = createGenerator( + { + token: "firefoxToken", + website: null, + }, + null, + ); + + const forwardEmail = createGenerator( + { + token: "forwardEmailToken", + domain: "example.com", + website: null, + }, + null, + ); + + const simpleLogin = createGenerator( + { + token: "simpleLoginToken", + baseUrl: "https://simplelogin.api.example.com", + website: null, + }, + null, + ); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + const result = await generator.getOptions(); + + expect(result).toEqual({ + type: "catchall", + wordCapitalize: true, + wordIncludeNumber: false, + subaddressType: "random", + subaddressEmail: "foo@example.com", + catchallType: "random", + catchallDomain: "example.com", + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "addyIoToken", + forwardedAnonAddyDomain: "addyio.example.com", + forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", + forwardedDuckDuckGoToken: "ddgToken", + forwardedFirefoxApiToken: "firefoxToken", + forwardedFastmailApiToken: "fastmailToken", + forwardedForwardEmailApiToken: "forwardEmailToken", + forwardedForwardEmailDomain: "example.com", + forwardedSimpleLoginApiKey: "simpleLoginToken", + forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", + }); + }); + + it("sets default options when an inner service lacks a value", async () => { + const account = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(null); + const catchall = createGenerator(null, DefaultCatchallOptions); + const effUsername = createGenerator( + null, + DefaultEffUsernameOptions, + ); + const subaddress = createGenerator( + null, + DefaultSubaddressOptions, + ); + const addyIo = createGenerator( + null, + DefaultAddyIoOptions, + ); + const duckDuckGo = createGenerator(null, DefaultDuckDuckGoOptions); + const fastmail = createGenerator( + null, + DefaultFastmailOptions, + ); + const firefoxRelay = createGenerator(null, DefaultFirefoxRelayOptions); + const forwardEmail = createGenerator( + null, + DefaultForwardEmailOptions, + ); + const simpleLogin = createGenerator(null, DefaultSimpleLoginOptions); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + const result = await generator.getOptions(); + + expect(result).toEqual({ + type: DefaultGeneratorNavigation.username, + catchallType: DefaultCatchallOptions.catchallType, + catchallDomain: DefaultCatchallOptions.catchallDomain, + wordCapitalize: DefaultEffUsernameOptions.wordCapitalize, + wordIncludeNumber: DefaultEffUsernameOptions.wordIncludeNumber, + subaddressType: DefaultSubaddressOptions.subaddressType, + subaddressEmail: DefaultSubaddressOptions.subaddressEmail, + forwardedService: DefaultGeneratorNavigation.forwarder, + forwardedAnonAddyApiToken: DefaultAddyIoOptions.token, + forwardedAnonAddyDomain: DefaultAddyIoOptions.domain, + forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl, + forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token, + forwardedFastmailApiToken: DefaultFastmailOptions.token, + forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token, + forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token, + forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain, + forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token, + forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl, + }); + }); + }); + + describe("saveOptions", () => { + it("saves option sets to its inner generators", async () => { + const account = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator({ type: "password" }); + const catchall = createGenerator(null, null); + const effUsername = createGenerator(null, null); + const subaddress = createGenerator(null, null); + const addyIo = createGenerator(null, null); + const duckDuckGo = createGenerator(null, null); + const fastmail = createGenerator(null, null); + const firefoxRelay = createGenerator(null, null); + const forwardEmail = createGenerator(null, null); + const simpleLogin = createGenerator(null, null); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + await generator.saveOptions({ + type: "catchall", + wordCapitalize: true, + wordIncludeNumber: false, + subaddressType: "random", + subaddressEmail: "foo@example.com", + catchallType: "random", + catchallDomain: "example.com", + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "addyIoToken", + forwardedAnonAddyDomain: "addyio.example.com", + forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", + forwardedDuckDuckGoToken: "ddgToken", + forwardedFirefoxApiToken: "firefoxToken", + forwardedFastmailApiToken: "fastmailToken", + forwardedForwardEmailApiToken: "forwardEmailToken", + forwardedForwardEmailDomain: "example.com", + forwardedSimpleLoginApiKey: "simpleLoginToken", + forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", + website: null, + }); + + expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { + type: "password", + username: "catchall", + forwarder: Forwarders.AddyIo.id, + }); + + expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, { + catchallDomain: "example.com", + catchallType: "random", + website: null, + }); + + expect(effUsername.saveOptions).toHaveBeenCalledWith(SomeUser, { + wordCapitalize: true, + wordIncludeNumber: false, + website: null, + }); + + expect(subaddress.saveOptions).toHaveBeenCalledWith(SomeUser, { + subaddressType: "random", + subaddressEmail: "foo@example.com", + website: null, + }); + + expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "addyIoToken", + domain: "addyio.example.com", + baseUrl: "https://addyio.api.example.com", + website: null, + }); + + expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "ddgToken", + website: null, + }); + + expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "fastmailToken", + website: null, + }); + + expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "firefoxToken", + website: null, + }); + + expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "forwardEmailToken", + domain: "example.com", + website: null, + }); + + expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "simpleLoginToken", + baseUrl: "https://simplelogin.api.example.com", + website: null, + }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.ts b/libs/common/src/tools/generator/legacy-username-generation.service.ts new file mode 100644 index 00000000000..7611a86c274 --- /dev/null +++ b/libs/common/src/tools/generator/legacy-username-generation.service.ts @@ -0,0 +1,383 @@ +import { zip, firstValueFrom, map, concatMap } from "rxjs"; + +import { ApiService } from "../../abstractions/api.service"; +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateProvider } from "../../platform/state"; + +import { GeneratorService, GeneratorNavigationService } from "./abstractions"; +import { UsernameGenerationServiceAbstraction } from "./abstractions/username-generation.service.abstraction"; +import { DefaultGeneratorService } from "./default-generator.service"; +import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; +import { GeneratorNavigation } from "./navigation/generator-navigation"; +import { NoPolicy } from "./no-policy"; +import { + CatchallGeneratorStrategy, + SubaddressGeneratorStrategy, + EffUsernameGeneratorStrategy, +} from "./username"; +import { CatchallGenerationOptions } from "./username/catchall-generator-options"; +import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options"; +import { AddyIoForwarder } from "./username/forwarders/addy-io"; +import { DuckDuckGoForwarder } from "./username/forwarders/duck-duck-go"; +import { FastmailForwarder } from "./username/forwarders/fastmail"; +import { FirefoxRelayForwarder } from "./username/forwarders/firefox-relay"; +import { ForwardEmailForwarder } from "./username/forwarders/forward-email"; +import { SimpleLoginForwarder } from "./username/forwarders/simple-login"; +import { Forwarders } from "./username/options/constants"; +import { + ApiOptions, + EmailDomainOptions, + EmailPrefixOptions, + RequestOptions, + SelfHostedApiOptions, +} from "./username/options/forwarder-options"; +import { SubaddressGenerationOptions } from "./username/subaddress-generator-options"; +import { UsernameGeneratorOptions } from "./username/username-generation-options"; +import { UsernameGenerationService } from "./username/username-generation.service"; + +type MappedOptions = { + generator: GeneratorNavigation; + algorithms: { + catchall: CatchallGenerationOptions; + effUsername: EffUsernameGenerationOptions; + subaddress: SubaddressGenerationOptions; + }; + forwarders: { + addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions; + duckDuckGo: ApiOptions & RequestOptions; + fastmail: ApiOptions & EmailPrefixOptions & RequestOptions; + firefoxRelay: ApiOptions & RequestOptions; + forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions; + simpleLogin: SelfHostedApiOptions & RequestOptions; + }; +}; + +export function legacyPasswordGenerationServiceFactory( + apiService: ApiService, + i18nService: I18nService, + cryptoService: CryptoService, + encryptService: EncryptService, + policyService: PolicyService, + accountService: AccountService, + stateProvider: StateProvider, +): UsernameGenerationServiceAbstraction { + // FIXME: Once the username generation service is replaced with this service + // in the clients, factor out the deprecated service in its entirety. + const deprecatedService = new UsernameGenerationService(cryptoService, null, null); + + const effUsername = new DefaultGeneratorService( + new EffUsernameGeneratorStrategy(deprecatedService, stateProvider), + policyService, + ); + + const subaddress = new DefaultGeneratorService( + new SubaddressGeneratorStrategy(deprecatedService, stateProvider), + policyService, + ); + + const catchall = new DefaultGeneratorService( + new CatchallGeneratorStrategy(deprecatedService, stateProvider), + policyService, + ); + + const addyIo = new DefaultGeneratorService( + new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const duckDuckGo = new DefaultGeneratorService( + new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const fastmail = new DefaultGeneratorService( + new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const firefoxRelay = new DefaultGeneratorService( + new FirefoxRelayForwarder( + apiService, + i18nService, + encryptService, + cryptoService, + stateProvider, + ), + policyService, + ); + + const forwardEmail = new DefaultGeneratorService( + new ForwardEmailForwarder( + apiService, + i18nService, + encryptService, + cryptoService, + stateProvider, + ), + policyService, + ); + + const simpleLogin = new DefaultGeneratorService( + new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); + + return new LegacyUsernameGenerationService( + accountService, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); +} + +/** Adapts the generator 2.0 design to 1.0 angular services. */ +export class LegacyUsernameGenerationService implements UsernameGenerationServiceAbstraction { + constructor( + private readonly accountService: AccountService, + private readonly navigation: GeneratorNavigationService, + private readonly catchall: GeneratorService, + private readonly effUsername: GeneratorService, + private readonly subaddress: GeneratorService, + private readonly addyIo: GeneratorService, + private readonly duckDuckGo: GeneratorService, + private readonly fastmail: GeneratorService, + private readonly firefoxRelay: GeneratorService, + private readonly forwardEmail: GeneratorService, + private readonly simpleLogin: GeneratorService, + ) {} + + generateUsername(options: UsernameGeneratorOptions) { + if (options.type === "catchall") { + return this.generateCatchall(options); + } else if (options.type === "subaddress") { + return this.generateSubaddress(options); + } else if (options.type === "forwarded") { + return this.generateForwarded(options); + } else { + return this.generateWord(options); + } + } + + generateWord(options: UsernameGeneratorOptions) { + return this.effUsername.generate(options); + } + + generateSubaddress(options: UsernameGeneratorOptions) { + return this.subaddress.generate(options); + } + + generateCatchall(options: UsernameGeneratorOptions) { + return this.catchall.generate(options); + } + + generateForwarded(options: UsernameGeneratorOptions) { + if (!options.forwardedService) { + return null; + } + + const stored = this.toStoredOptions(options); + switch (options.forwardedService) { + case Forwarders.AddyIo.id: + return this.addyIo.generate(stored.forwarders.addyIo); + case Forwarders.DuckDuckGo.id: + return this.duckDuckGo.generate(stored.forwarders.duckDuckGo); + case Forwarders.Fastmail.id: + return this.fastmail.generate(stored.forwarders.fastmail); + case Forwarders.FirefoxRelay.id: + return this.firefoxRelay.generate(stored.forwarders.firefoxRelay); + case Forwarders.ForwardEmail.id: + return this.forwardEmail.generate(stored.forwarders.forwardEmail); + case Forwarders.SimpleLogin.id: + return this.simpleLogin.generate(stored.forwarders.simpleLogin); + } + } + + getOptions() { + const options$ = this.accountService.activeAccount$.pipe( + concatMap((account) => + zip( + this.navigation.options$(account.id), + this.navigation.defaults$(account.id), + this.catchall.options$(account.id), + this.catchall.defaults$(account.id), + this.effUsername.options$(account.id), + this.effUsername.defaults$(account.id), + this.subaddress.options$(account.id), + this.subaddress.defaults$(account.id), + this.addyIo.options$(account.id), + this.addyIo.defaults$(account.id), + this.duckDuckGo.options$(account.id), + this.duckDuckGo.defaults$(account.id), + this.fastmail.options$(account.id), + this.fastmail.defaults$(account.id), + this.firefoxRelay.options$(account.id), + this.firefoxRelay.defaults$(account.id), + this.forwardEmail.options$(account.id), + this.forwardEmail.defaults$(account.id), + this.simpleLogin.options$(account.id), + this.simpleLogin.defaults$(account.id), + ), + ), + map( + ([ + generatorOptions, + generatorDefaults, + catchallOptions, + catchallDefaults, + effUsernameOptions, + effUsernameDefaults, + subaddressOptions, + subaddressDefaults, + addyIoOptions, + addyIoDefaults, + duckDuckGoOptions, + duckDuckGoDefaults, + fastmailOptions, + fastmailDefaults, + firefoxRelayOptions, + firefoxRelayDefaults, + forwardEmailOptions, + forwardEmailDefaults, + simpleLoginOptions, + simpleLoginDefaults, + ]) => + this.toUsernameOptions({ + generator: generatorOptions ?? generatorDefaults, + algorithms: { + catchall: catchallOptions ?? catchallDefaults, + effUsername: effUsernameOptions ?? effUsernameDefaults, + subaddress: subaddressOptions ?? subaddressDefaults, + }, + forwarders: { + addyIo: addyIoOptions ?? addyIoDefaults, + duckDuckGo: duckDuckGoOptions ?? duckDuckGoDefaults, + fastmail: fastmailOptions ?? fastmailDefaults, + firefoxRelay: firefoxRelayOptions ?? firefoxRelayDefaults, + forwardEmail: forwardEmailOptions ?? forwardEmailDefaults, + simpleLogin: simpleLoginOptions ?? simpleLoginDefaults, + }, + }), + ), + ); + + return firstValueFrom(options$); + } + + async saveOptions(options: UsernameGeneratorOptions) { + const stored = this.toStoredOptions(options); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a.id))); + + // generator settings needs to preserve whether password or passphrase is selected, + // so `navigationOptions` is mutated. + let navigationOptions = await firstValueFrom(this.navigation.options$(userId)); + navigationOptions = Object.assign(navigationOptions, stored.generator); + await this.navigation.saveOptions(userId, navigationOptions); + + // overwrite all other settings with latest values + await Promise.all([ + this.catchall.saveOptions(userId, stored.algorithms.catchall), + this.effUsername.saveOptions(userId, stored.algorithms.effUsername), + this.subaddress.saveOptions(userId, stored.algorithms.subaddress), + this.addyIo.saveOptions(userId, stored.forwarders.addyIo), + this.duckDuckGo.saveOptions(userId, stored.forwarders.duckDuckGo), + this.fastmail.saveOptions(userId, stored.forwarders.fastmail), + this.firefoxRelay.saveOptions(userId, stored.forwarders.firefoxRelay), + this.forwardEmail.saveOptions(userId, stored.forwarders.forwardEmail), + this.simpleLogin.saveOptions(userId, stored.forwarders.simpleLogin), + ]); + } + + private toStoredOptions(options: UsernameGeneratorOptions) { + const forwarders = { + addyIo: { + baseUrl: options.forwardedAnonAddyBaseUrl, + token: options.forwardedAnonAddyApiToken, + domain: options.forwardedAnonAddyDomain, + website: options.website, + }, + duckDuckGo: { + token: options.forwardedDuckDuckGoToken, + website: options.website, + }, + fastmail: { + token: options.forwardedFastmailApiToken, + website: options.website, + }, + firefoxRelay: { + token: options.forwardedFirefoxApiToken, + website: options.website, + }, + forwardEmail: { + token: options.forwardedForwardEmailApiToken, + domain: options.forwardedForwardEmailDomain, + website: options.website, + }, + simpleLogin: { + token: options.forwardedSimpleLoginApiKey, + baseUrl: options.forwardedSimpleLoginBaseUrl, + website: options.website, + }, + }; + + const generator = { + username: options.type, + forwarder: options.forwardedService, + }; + + const algorithms = { + effUsername: { + wordCapitalize: options.wordCapitalize, + wordIncludeNumber: options.wordIncludeNumber, + website: options.website, + }, + subaddress: { + subaddressType: options.subaddressType, + subaddressEmail: options.subaddressEmail, + website: options.website, + }, + catchall: { + catchallType: options.catchallType, + catchallDomain: options.catchallDomain, + website: options.website, + }, + }; + + return { generator, algorithms, forwarders } as MappedOptions; + } + + private toUsernameOptions(options: MappedOptions) { + return { + type: options.generator.username, + wordCapitalize: options.algorithms.effUsername.wordCapitalize, + wordIncludeNumber: options.algorithms.effUsername.wordIncludeNumber, + subaddressType: options.algorithms.subaddress.subaddressType, + subaddressEmail: options.algorithms.subaddress.subaddressEmail, + catchallType: options.algorithms.catchall.catchallType, + catchallDomain: options.algorithms.catchall.catchallDomain, + forwardedService: options.generator.forwarder, + forwardedAnonAddyApiToken: options.forwarders.addyIo.token, + forwardedAnonAddyDomain: options.forwarders.addyIo.domain, + forwardedAnonAddyBaseUrl: options.forwarders.addyIo.baseUrl, + forwardedDuckDuckGoToken: options.forwarders.duckDuckGo.token, + forwardedFirefoxApiToken: options.forwarders.firefoxRelay.token, + forwardedFastmailApiToken: options.forwarders.fastmail.token, + forwardedForwardEmailApiToken: options.forwarders.forwardEmail.token, + forwardedForwardEmailDomain: options.forwarders.forwardEmail.domain, + forwardedSimpleLoginApiKey: options.forwarders.simpleLogin.token, + forwardedSimpleLoginBaseUrl: options.forwarders.simpleLogin.baseUrl, + } as UsernameGeneratorOptions; + } +} diff --git a/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts b/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts new file mode 100644 index 00000000000..6853542bb7a --- /dev/null +++ b/libs/common/src/tools/generator/navigation/default-generator-nativation.service.spec.ts @@ -0,0 +1,100 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { UserId } from "../../../types/guid"; +import { GENERATOR_SETTINGS } from "../key-definitions"; + +import { + GeneratorNavigationEvaluator, + DefaultGeneratorNavigationService, + DefaultGeneratorNavigation, +} from "./"; + +const SomeUser = "some user" as UserId; + +describe("DefaultGeneratorNavigationService", () => { + describe("options$", () => { + it("emits options", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const settings = { type: "password" as const }; + await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser); + const navigation = new DefaultGeneratorNavigationService(stateProvider, null); + + const result = await firstValueFrom(navigation.options$(SomeUser)); + + expect(result).toEqual(settings); + }); + }); + + describe("defaults$", () => { + it("emits default options", async () => { + const navigation = new DefaultGeneratorNavigationService(null, null); + + const result = await firstValueFrom(navigation.defaults$(SomeUser)); + + expect(result).toEqual(DefaultGeneratorNavigation); + }); + }); + + describe("evaluator$", () => { + it("emits a GeneratorNavigationEvaluator", async () => { + const policyService = mock({ + getAll$() { + return of([]); + }, + }); + const navigation = new DefaultGeneratorNavigationService(null, policyService); + + const result = await firstValueFrom(navigation.evaluator$(SomeUser)); + + expect(result).toBeInstanceOf(GeneratorNavigationEvaluator); + }); + }); + + describe("enforcePolicy", () => { + it("applies policy", async () => { + const policyService = mock({ + getAll$(_type: PolicyType, _user: UserId) { + return of([ + new Policy({ + id: "" as any, + organizationId: "" as any, + enabled: true, + type: PolicyType.PasswordGenerator, + data: { defaultType: "password" }, + }), + ]); + }, + }); + const navigation = new DefaultGeneratorNavigationService(null, policyService); + const options = {}; + + const result = await navigation.enforcePolicy(SomeUser, options); + + expect(result).toMatchObject({ type: "password" }); + }); + }); + + describe("saveOptions", () => { + it("updates options$", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const navigation = new DefaultGeneratorNavigationService(stateProvider, null); + const settings = { type: "password" as const }; + + await navigation.saveOptions(SomeUser, settings); + const result = await firstValueFrom(navigation.options$(SomeUser)); + + expect(result).toEqual(settings); + }); + }); +}); diff --git a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts new file mode 100644 index 00000000000..3199efc8c3f --- /dev/null +++ b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts @@ -0,0 +1,71 @@ +import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs"; + +import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "../../../admin-console/enums"; +import { StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction"; +import { GENERATOR_SETTINGS } from "../key-definitions"; +import { reduceCollection } from "../reduce-collection.operator"; + +import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; +import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; + +export class DefaultGeneratorNavigationService implements GeneratorNavigationService { + /** instantiates the password generator strategy. + * @param stateProvider provides durable state + * @param policy provides the policy to enforce + */ + constructor( + private readonly stateProvider: StateProvider, + private readonly policy: PolicyService, + ) {} + + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + * @param userId: Identifies the user making the request + */ + options$(userId: UserId): Observable { + return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId); + } + + /** Gets the default options. */ + defaults$(userId: UserId): Observable { + return new BehaviorSubject({ ...DefaultGeneratorNavigation }); + } + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + * @param userId: Identifies the user making the request + */ + evaluator$(userId: UserId) { + const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( + reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), + map((policy) => new GeneratorNavigationEvaluator(policy)), + ); + + return evaluator$; + } + + /** Enforces the policy on the given options + * @param userId: Identifies the user making the request + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + async enforcePolicy(userId: UserId, options: GeneratorNavigation) { + const evaluator = await firstValueFrom(this.evaluator$(userId)); + const applied = evaluator.applyPolicy(options); + const sanitized = evaluator.sanitize(applied); + return sanitized; + } + + /** Saves the navigation options to disk. + * @param userId: Identifies the user making the request + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + async saveOptions(userId: UserId, options: GeneratorNavigation): Promise { + await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId); + } +} diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts new file mode 100644 index 00000000000..58560fb5a04 --- /dev/null +++ b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.spec.ts @@ -0,0 +1,64 @@ +import { DefaultGeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; + +describe("GeneratorNavigationEvaluator", () => { + describe("policyInEffect", () => { + it.each([["passphrase"], ["password"]] as const)( + "returns true if the policy has a defaultType (= %p)", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + expect(evaluator.policyInEffect).toEqual(true); + }, + ); + + it.each([[undefined], [null], ["" as any]])( + "returns false if the policy has a falsy defaultType (= %p)", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + expect(evaluator.policyInEffect).toEqual(false); + }, + ); + }); + + describe("applyPolicy", () => { + it("returns the input options", () => { + const evaluator = new GeneratorNavigationEvaluator(null); + const options = { type: "password" as const }; + + const result = evaluator.applyPolicy(options); + + expect(result).toEqual(options); + }); + }); + + describe("sanitize", () => { + it.each([["passphrase"], ["password"]] as const)( + "defaults options to the policy's default type (= %p) when a policy is in effect", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + const result = evaluator.sanitize({}); + + expect(result).toEqual({ type: defaultType }); + }, + ); + + it("defaults options to the default generator navigation type when a policy is not in effect", () => { + const evaluator = new GeneratorNavigationEvaluator(null); + + const result = evaluator.sanitize({}); + + expect(result.type).toEqual(DefaultGeneratorNavigation.type); + }); + + it("retains the options type when it is set", () => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" }); + + const result = evaluator.sanitize({ type: "password" }); + + expect(result).toEqual({ type: "password" }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts new file mode 100644 index 00000000000..e580f130b53 --- /dev/null +++ b/libs/common/src/tools/generator/navigation/generator-navigation-evaluator.ts @@ -0,0 +1,43 @@ +import { PolicyEvaluator } from "../abstractions"; + +import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationPolicy } from "./generator-navigation-policy"; + +/** Enforces policy for generator navigation options. + */ +export class GeneratorNavigationEvaluator + implements PolicyEvaluator +{ + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(readonly policy: GeneratorNavigationPolicy) {} + + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + return this.policy?.defaultType ? true : false; + } + + /** Apply policy to the input options. + * @param options The options to build from. These options are not altered. + * @returns A new password generation request with policy applied. + */ + applyPolicy(options: GeneratorNavigation): GeneratorNavigation { + return options; + } + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A passphrase generation request with cascade applied. + */ + sanitize(options: GeneratorNavigation): GeneratorNavigation { + const defaultType = this.policyInEffect + ? this.policy.defaultType + : DefaultGeneratorNavigation.type; + return { + ...options, + type: options.type ?? defaultType, + }; + } +} diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts b/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts new file mode 100644 index 00000000000..ed8fe731a75 --- /dev/null +++ b/libs/common/src/tools/generator/navigation/generator-navigation-policy.spec.ts @@ -0,0 +1,63 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PolicyId } from "../../../types/guid"; + +import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); + + expect(result).toEqual(DisabledGeneratorNavigationPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); + + expect(result).toEqual(DisabledGeneratorNavigationPolicy); + }); + + it("should take the %p from the policy", () => { + const policy = createPolicy({ defaultType: "passphrase" }); + + const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy); + + expect(result).toEqual({ defaultType: "passphrase" }); + }); + + it("should override passphrase with password", () => { + const policy = createPolicy({ defaultType: "password" }); + + const result = preferPassword({ defaultType: "passphrase" }, policy); + + expect(result).toEqual({ defaultType: "password" }); + }); + + it("should not override password", () => { + const policy = createPolicy({ defaultType: "passphrase" }); + + const result = preferPassword({ defaultType: "password" }, policy); + + expect(result).toEqual({ defaultType: "password" }); + }); +}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts b/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts new file mode 100644 index 00000000000..25c2a73337e --- /dev/null +++ b/libs/common/src/tools/generator/navigation/generator-navigation-policy.ts @@ -0,0 +1,39 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { GeneratorType } from "../generator-type"; + +/** Policy settings affecting password generator navigation */ +export type GeneratorNavigationPolicy = { + /** The type of generator that should be shown by default when opening + * the password generator. + */ + defaultType?: GeneratorType; +}; + +/** Reduces a policy into an accumulator by preferring the password generator + * type to other generator types. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the resulting `GeneratorNavigationPolicy` + */ +export function preferPassword( + acc: GeneratorNavigationPolicy, + policy: Policy, +): GeneratorNavigationPolicy { + const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled; + if (!isEnabled) { + return acc; + } + + const isOverridable = acc.defaultType !== "password" && policy.data.defaultType; + const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc; + + return result; +} + +/** The default options for password generation policy. */ +export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({ + defaultType: undefined, +}); diff --git a/libs/common/src/tools/generator/navigation/generator-navigation.ts b/libs/common/src/tools/generator/navigation/generator-navigation.ts new file mode 100644 index 00000000000..6a07385286d --- /dev/null +++ b/libs/common/src/tools/generator/navigation/generator-navigation.ts @@ -0,0 +1,26 @@ +import { GeneratorType } from "../generator-type"; +import { ForwarderId } from "../username/options"; +import { UsernameGeneratorType } from "../username/options/generator-options"; + +/** Stores credential generator UI state. */ + +export type GeneratorNavigation = { + /** The kind of credential being generated. + * @remarks The legacy generator only supports "password" and "passphrase". + * The componentized generator supports all values. + */ + type?: GeneratorType; + + /** When `type === "username"`, this stores the username algorithm. */ + username?: UsernameGeneratorType; + + /** When `username === "forwarded"`, this stores the forwarder implementation. */ + forwarder?: ForwarderId | ""; +}; +/** The default options for password generation. */ + +export const DefaultGeneratorNavigation: Partial = Object.freeze({ + type: "password", + username: "word", + forwarder: "", +}); diff --git a/libs/common/src/tools/generator/navigation/index.ts b/libs/common/src/tools/generator/navigation/index.ts new file mode 100644 index 00000000000..86194f471af --- /dev/null +++ b/libs/common/src/tools/generator/navigation/index.ts @@ -0,0 +1,3 @@ +export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; +export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service"; +export { GeneratorNavigation, DefaultGeneratorNavigation } from "./generator-navigation"; diff --git a/libs/common/src/tools/generator/passphrase/index.ts b/libs/common/src/tools/generator/passphrase/index.ts index 175f15663e4..3bbe9253017 100644 --- a/libs/common/src/tools/generator/passphrase/index.ts +++ b/libs/common/src/tools/generator/passphrase/index.ts @@ -2,4 +2,7 @@ export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; export { PassphraseGeneratorPolicy } from "./passphrase-generator-policy"; export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; -export { DefaultPassphraseGenerationOptions } from "./passphrase-generation-options"; +export { + DefaultPassphraseGenerationOptions, + PassphraseGenerationOptions, +} from "./passphrase-generation-options"; diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index b7f09bd717d..adcfc395273 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -2,7 +2,6 @@ * include structuredClone in test environment. * @jest-environment ../../../../shared/test.environment.ts */ - import { mock } from "jest-mock-extended"; import { of, firstValueFrom } from "rxjs"; @@ -12,12 +11,16 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy"; -import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from "."; +import { + DefaultPassphraseGenerationOptions, + PassphraseGeneratorOptionsEvaluator, + PassphraseGeneratorStrategy, +} from "."; const SomeUser = "some user" as UserId; @@ -71,6 +74,16 @@ describe("Password generation strategy", () => { }); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new PassphraseGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultPassphraseGenerationOptions); + }); + }); + describe("cache_ms", () => { it("should be a positive non-zero number", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index f193b2b3266..1a7c24082f3 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -1,14 +1,17 @@ -import { map, pipe } from "rxjs"; +import { BehaviorSubject, map, pipe } from "rxjs"; import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; import { reduceCollection } from "../reduce-collection.operator"; -import { PassphraseGenerationOptions } from "./passphrase-generation-options"; +import { + PassphraseGenerationOptions, + DefaultPassphraseGenerationOptions, +} from "./passphrase-generation-options"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; import { DisabledPassphraseGeneratorPolicy, @@ -36,6 +39,11 @@ export class PassphraseGeneratorStrategy return this.stateProvider.getUser(id, PASSPHRASE_SETTINGS); } + /** Gets the default options. */ + defaults$(_: UserId) { + return new BehaviorSubject({ ...DefaultPassphraseGenerationOptions }).asObservable(); + } + /** {@link GeneratorStrategy.policy} */ get policy() { return PolicyType.PasswordGenerator; diff --git a/libs/common/src/tools/generator/password/index.ts b/libs/common/src/tools/generator/password/index.ts index 0fcbbf5616a..e17ab8201c9 100644 --- a/libs/common/src/tools/generator/password/index.ts +++ b/libs/common/src/tools/generator/password/index.ts @@ -6,6 +6,6 @@ export { PasswordGeneratorStrategy } from "./password-generator-strategy"; // legacy interfaces export { PasswordGeneratorOptions } from "./password-generator-options"; -export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; +export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; export { PasswordGenerationService } from "./password-generation.service"; export { GeneratedPasswordHistory } from "./generated-password-history"; diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts index eb1f08d97e6..fced2dfe43f 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -5,10 +5,10 @@ import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { EFFLongWordList } from "../../../platform/misc/wordlist"; import { EncString } from "../../../platform/models/domain/enc-string"; +import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PassphraseGeneratorOptionsEvaluator } from "../passphrase/passphrase-generator-options-evaluator"; import { GeneratedPasswordHistory } from "./generated-password-history"; -import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; import { PasswordGeneratorOptions } from "./password-generator-options"; import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; @@ -341,24 +341,6 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId }); } - normalizeOptions( - options: PasswordGeneratorOptions, - enforcedPolicyOptions: PasswordGeneratorPolicyOptions, - ) { - const evaluator = - options.type == "password" - ? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions) - : new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions); - - const evaluatedOptions = evaluator.applyPolicy(options); - const santizedOptions = evaluator.sanitize(evaluatedOptions); - - // callers assume this function updates the options parameter - Object.assign(options, santizedOptions); - - return options; - } - private capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts index a0b42b30321..aa0a6f7dabb 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -1,3 +1,4 @@ +import { GeneratorNavigation } from "../navigation/generator-navigation"; import { PassphraseGenerationOptions } from "../passphrase/passphrase-generation-options"; import { PasswordGenerationOptions } from "./password-generation-options"; @@ -6,12 +7,5 @@ import { PasswordGenerationOptions } from "./password-generation-options"; * This type includes all properties suitable for reactive data binding. */ export type PasswordGeneratorOptions = PasswordGenerationOptions & - PassphraseGenerationOptions & { - /** The algorithm to use for credential generation. - * Properties on @see PasswordGenerationOptions should be processed - * only when `type === "password"`. - * Properties on @see PassphraseGenerationOptions should be processed - * only when `type === "passphrase"`. - */ - type?: "password" | "passphrase"; - }; + PassphraseGenerationOptions & + GeneratorNavigation; diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 9bfa5b5f352..5efc6a85a74 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -17,6 +17,7 @@ import { PASSWORD_SETTINGS } from "../key-definitions"; import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy"; import { + DefaultPasswordGenerationOptions, PasswordGenerationServiceAbstraction, PasswordGeneratorOptionsEvaluator, PasswordGeneratorStrategy, @@ -82,6 +83,16 @@ describe("Password generation strategy", () => { }); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new PasswordGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultPasswordGenerationOptions); + }); + }); + describe("cache_ms", () => { it("should be a positive non-zero number", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index f8d618128b1..e98ae6fb161 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -1,14 +1,17 @@ -import { map, pipe } from "rxjs"; +import { BehaviorSubject, map, pipe } from "rxjs"; import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSWORD_SETTINGS } from "../key-definitions"; import { reduceCollection } from "../reduce-collection.operator"; -import { PasswordGenerationOptions } from "./password-generation-options"; -import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; +import { + DefaultPasswordGenerationOptions, + PasswordGenerationOptions, +} from "./password-generation-options"; import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; import { DisabledPasswordGeneratorPolicy, @@ -35,6 +38,11 @@ export class PasswordGeneratorStrategy return this.stateProvider.getUser(id, PASSWORD_SETTINGS); } + /** Gets the default options. */ + defaults$(_: UserId) { + return new BehaviorSubject({ ...DefaultPasswordGenerationOptions }).asObservable(); + } + /** {@link GeneratorStrategy.policy} */ get policy() { return PolicyType.PasswordGenerator; diff --git a/libs/common/src/tools/generator/state/padded-data-packer.ts b/libs/common/src/tools/generator/state/padded-data-packer.ts index e2f5058b217..d1573e5cb79 100644 --- a/libs/common/src/tools/generator/state/padded-data-packer.ts +++ b/libs/common/src/tools/generator/state/padded-data-packer.ts @@ -58,11 +58,12 @@ export class PaddedDataPacker extends DataPackerAbstraction { /** {@link DataPackerAbstraction.unpack} */ unpack(secret: string): Jsonify { // frame size is stored before the JSON payload in base 10 - const frameBreakpoint = secret.indexOf(DATA_PACKING.divider); - if (frameBreakpoint < 1) { + const frameEndIndex = secret.indexOf(DATA_PACKING.divider); + if (frameEndIndex < 1) { throw new Error("missing frame size"); } - const frameSize = parseInt(secret.slice(0, frameBreakpoint), 10); + const frameSize = parseInt(secret.slice(0, frameEndIndex), 10); + const dataStartIndex = frameEndIndex + 1; // The decrypted string should be a multiple of the frame length if (secret.length % frameSize > 0) { @@ -70,20 +71,20 @@ export class PaddedDataPacker extends DataPackerAbstraction { } // encoded data terminates with the divider, followed by the padding character - const jsonBreakpoint = secret.lastIndexOf(DATA_PACKING.divider); - if (jsonBreakpoint == frameBreakpoint) { + const dataEndIndex = secret.lastIndexOf(DATA_PACKING.divider); + if (dataEndIndex == frameEndIndex) { throw new Error("missing json object"); } - const paddingBegins = jsonBreakpoint + 1; + const paddingStartIndex = dataEndIndex + 1; // If the padding contains invalid padding characters then the padding could be used // as a side channel for arbitrary data. - if (secret.slice(paddingBegins).match(DATA_PACKING.hasInvalidPadding)) { + if (secret.slice(paddingStartIndex).match(DATA_PACKING.hasInvalidPadding)) { throw new Error("invalid padding"); } // remove frame size and padding - const b64 = secret.substring(frameBreakpoint, paddingBegins); + const b64 = secret.slice(dataStartIndex, dataEndIndex); // unpack the stored data const json = Utils.fromB64ToUtf8(b64); diff --git a/libs/common/src/tools/generator/username/catchall-generator-options.ts b/libs/common/src/tools/generator/username/catchall-generator-options.ts index 7e9950ec45f..bddf98f7576 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-options.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-options.ts @@ -1,10 +1,21 @@ +import { RequestOptions } from "./options/forwarder-options"; +import { UsernameGenerationMode } from "./options/generator-options"; + /** Settings supported when generating an email subaddress */ export type CatchallGenerationOptions = { - type?: "random" | "website-name"; - domain?: string; -}; + /** selects the generation algorithm for the catchall email address. */ + catchallType?: UsernameGenerationMode; -/** The default options for email subaddress generation. */ -export const DefaultCatchallOptions: Partial = Object.freeze({ - type: "random", + /** The domain part of the generated email address. + * @example If the domain is `domain.io` and the generated username + * is `jd`, then the generated email address will be `jd@mydomain.io` + */ + catchallDomain?: string; +} & RequestOptions; + +/** The default options for catchall address generation. */ +export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({ + catchallType: "random", + catchallDomain: "", + website: null, }); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index 339e4b27203..52cfa00aaf1 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -10,6 +10,8 @@ import { UserId } from "../../../types/guid"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { CATCHALL_SETTINGS } from "../key-definitions"; +import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; + import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; @@ -47,6 +49,16 @@ describe("Email subaddress list generation strategy", () => { }); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new CatchallGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultCatchallOptions); + }); + }); + describe("cache_ms", () => { it("should be a positive non-zero number", () => { const legacy = mock(); @@ -70,16 +82,14 @@ describe("Email subaddress list generation strategy", () => { const legacy = mock(); const strategy = new CatchallGeneratorStrategy(legacy, null); const options = { - type: "website-name" as const, - domain: "example.com", - }; + catchallType: "website-name", + catchallDomain: "example.com", + website: "foo.com", + } as CatchallGenerationOptions; await strategy.generate(options); - expect(legacy.generateCatchall).toHaveBeenCalledWith({ - catchallType: "website-name" as const, - catchallDomain: "example.com", - }); + expect(legacy.generateCatchall).toHaveBeenCalledWith(options); }); }); }); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index 6b36ebd50b5..5111b06e90c 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -1,15 +1,15 @@ -import { map, pipe } from "rxjs"; +import { BehaviorSubject, map, pipe } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; +import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { CATCHALL_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; -import { CatchallGenerationOptions } from "./catchall-generator-options"; -import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; +import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; const ONE_MINUTE = 60 * 1000; @@ -30,6 +30,11 @@ export class CatchallGeneratorStrategy return this.stateProvider.getUser(id, CATCHALL_SETTINGS); } + /** {@link GeneratorStrategy.defaults$} */ + defaults$(userId: UserId) { + return new BehaviorSubject({ ...DefaultCatchallOptions }).asObservable(); + } + /** {@link GeneratorStrategy.policy} */ get policy() { // Uses password generator since there aren't policies @@ -49,9 +54,6 @@ export class CatchallGeneratorStrategy /** {@link GeneratorStrategy.generate} */ generate(options: CatchallGenerationOptions) { - return this.usernameService.generateCatchall({ - catchallDomain: options.domain, - catchallType: options.type, - }); + return this.usernameService.generateCatchall(options); } } diff --git a/libs/common/src/tools/generator/username/eff-username-generator-options.ts b/libs/common/src/tools/generator/username/eff-username-generator-options.ts index 868149c2fdc..07890b3d55e 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-options.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-options.ts @@ -1,11 +1,17 @@ -/** Settings supported when generating an ASCII username */ +import { RequestOptions } from "./options/forwarder-options"; + +/** Settings supported when generating a username using the EFF word list */ export type EffUsernameGenerationOptions = { + /** when true, the word is capitalized */ wordCapitalize?: boolean; + + /** when true, a random number is appended to the username */ wordIncludeNumber?: boolean; -}; +} & RequestOptions; /** The default options for EFF long word generation. */ -export const DefaultEffUsernameOptions: Partial = Object.freeze({ +export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({ wordCapitalize: false, wordIncludeNumber: false, + website: null, }); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 821b4bb7dc8..9b0e4cc0694 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -10,6 +10,8 @@ import { UserId } from "../../../types/guid"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { EFF_USERNAME_SETTINGS } from "../key-definitions"; +import { DefaultEffUsernameOptions } from "./eff-username-generator-options"; + import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; @@ -47,6 +49,16 @@ describe("EFF long word list generation strategy", () => { }); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new EffUsernameGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultEffUsernameOptions); + }); + }); + describe("cache_ms", () => { it("should be a positive non-zero number", () => { const legacy = mock(); @@ -72,6 +84,7 @@ describe("EFF long word list generation strategy", () => { const options = { wordCapitalize: false, wordIncludeNumber: false, + website: null as string, }; await strategy.generate(options); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index 133b4e77776..1a4efdcb44b 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -1,15 +1,18 @@ -import { map, pipe } from "rxjs"; +import { BehaviorSubject, map, pipe } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; +import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; -import { EffUsernameGenerationOptions } from "./eff-username-generator-options"; -import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; +import { + DefaultEffUsernameOptions, + EffUsernameGenerationOptions, +} from "./eff-username-generator-options"; const ONE_MINUTE = 60 * 1000; @@ -30,6 +33,11 @@ export class EffUsernameGeneratorStrategy return this.stateProvider.getUser(id, EFF_USERNAME_SETTINGS); } + /** {@link GeneratorStrategy.defaults$} */ + defaults$(userId: UserId) { + return new BehaviorSubject({ ...DefaultEffUsernameOptions }).asObservable(); + } + /** {@link GeneratorStrategy.policy} */ get policy() { // Uses password generator since there aren't policies diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index 30dd6204843..c2a606eae0a 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -15,6 +15,7 @@ import { DUCK_DUCK_GO_FORWARDER } from "../key-definitions"; import { SecretState } from "../state/secret-state"; import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; +import { DefaultDuckDuckGoOptions } from "./forwarders/duck-duck-go"; import { ApiOptions } from "./options/forwarder-options"; class TestForwarder extends ForwarderGeneratorStrategy { @@ -30,6 +31,10 @@ class TestForwarder extends ForwarderGeneratorStrategy { // arbitrary. return DUCK_DUCK_GO_FORWARDER; } + + defaults$ = (userId: UserId) => { + return of(DefaultDuckDuckGoOptions); + }; } const SomeUser = "some user" as UserId; diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index 8b78f22634e..086e3476698 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -1,4 +1,4 @@ -import { map, pipe } from "rxjs"; +import { Observable, map, pipe } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; @@ -79,6 +79,9 @@ export abstract class ForwarderGeneratorStrategy< return new UserKeyEncryptor(this.encryptService, this.keyService, packer); } + /** Gets the default options. */ + abstract defaults$: (userId: UserId) => Observable; + /** Determine where forwarder configuration is stored */ protected abstract readonly key: KeyDefinition; diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts index c2428aefcaa..f42ca23c113 100644 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts @@ -2,12 +2,17 @@ * include Request in test environment. * @jest-environment ../../../../shared/test.environment.ts */ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "../../../../types/guid"; import { ADDY_IO_FORWARDER } from "../../key-definitions"; import { Forwarders } from "../options/constants"; -import { AddyIoForwarder } from "./addy-io"; +import { AddyIoForwarder, DefaultAddyIoOptions } from "./addy-io"; import { mockApiService, mockI18nService } from "./mocks.jest"; +const SomeUser = "some user" as UserId; + describe("Addy.io Forwarder", () => { it("key returns the Addy IO forwarder key", () => { const forwarder = new AddyIoForwarder(null, null, null, null, null); @@ -15,6 +20,16 @@ describe("Addy.io Forwarder", () => { expect(forwarder.key).toBe(ADDY_IO_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new AddyIoForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultAddyIoOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(200, {}); diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts index 2db69e2396c..3e4960f7e76 100644 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.ts +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.ts @@ -1,13 +1,23 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { ADDY_IO_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options"; +export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({ + website: null, + baseUrl: "https://app.addy.io", + token: "", + domain: "", +}); + /** Generates a forwarding address for addy.io (formerly anon addy) */ export class AddyIoForwarder extends ForwarderGeneratorStrategy< SelfHostedApiOptions & EmailDomainOptions @@ -34,6 +44,11 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy< return ADDY_IO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.defaults$} */ + defaults$ = (userId: UserId) => { + return new BehaviorSubject({ ...DefaultAddyIoOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { @@ -91,3 +106,10 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy< } }; } + +export const DefaultOptions = Object.freeze({ + website: null, + baseUrl: "https://app.addy.io", + domain: "", + token: "", +}); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts index 211eaead6dc..b836ca2bef7 100644 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts +++ b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.spec.ts @@ -2,12 +2,17 @@ * include Request in test environment. * @jest-environment ../../../../shared/test.environment.ts */ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "../../../../types/guid"; import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions"; import { Forwarders } from "../options/constants"; -import { DuckDuckGoForwarder } from "./duck-duck-go"; +import { DuckDuckGoForwarder, DefaultDuckDuckGoOptions } from "./duck-duck-go"; import { mockApiService, mockI18nService } from "./mocks.jest"; +const SomeUser = "some user" as UserId; + describe("DuckDuckGo Forwarder", () => { it("key returns the Duck Duck Go forwarder key", () => { const forwarder = new DuckDuckGoForwarder(null, null, null, null, null); @@ -15,6 +20,16 @@ describe("DuckDuckGo Forwarder", () => { expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new DuckDuckGoForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultDuckDuckGoOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(200, {}); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts index daf4f7b4445..9b5d93d742e 100644 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts +++ b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts @@ -1,13 +1,21 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; +export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({ + website: null, + token: "", +}); + /** Generates a forwarding address for DuckDuckGo */ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy { /** Instantiates the forwarder @@ -32,6 +40,11 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy return DUCK_DUCK_GO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.defaults$} */ + defaults$ = (userId: UserId) => { + return new BehaviorSubject({ ...DefaultDuckDuckGoOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions): Promise => { if (!options.token || options.token === "") { @@ -68,3 +81,8 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy } }; } + +export const DefaultOptions = Object.freeze({ + website: null, + token: "", +}); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts index bab2b93966a..895f32f7eeb 100644 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.spec.ts @@ -2,13 +2,18 @@ * include Request in test environment. * @jest-environment ../../../../shared/test.environment.ts */ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; +import { UserId } from "../../../../types/guid"; import { FASTMAIL_FORWARDER } from "../../key-definitions"; import { Forwarders } from "../options/constants"; -import { FastmailForwarder } from "./fastmail"; +import { FastmailForwarder, DefaultFastmailOptions } from "./fastmail"; import { mockI18nService } from "./mocks.jest"; +const SomeUser = "some user" as UserId; + type MockResponse = { status: number; body: any }; // fastmail calls nativeFetch first to resolve the accountId, @@ -52,6 +57,16 @@ describe("Fastmail Forwarder", () => { expect(forwarder.key).toBe(FASTMAIL_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new FastmailForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultFastmailOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(AccountIdSuccess, EmptyResponse); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts index b4e2b56695b..9d62cd0039d 100644 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.ts +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.ts @@ -1,13 +1,23 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { FASTMAIL_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options"; +export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({ + website: null, + domain: "", + prefix: "", + token: "", +}); + /** Generates a forwarding address for Fastmail */ export class FastmailForwarder extends ForwarderGeneratorStrategy { /** Instantiates the forwarder @@ -32,6 +42,11 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { + return new BehaviorSubject({ ...DefaultFastmailOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions & EmailPrefixOptions) => { if (!options.token || options.token === "") { @@ -141,3 +156,10 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { it("key returns the Firefox Relay forwarder key", () => { const forwarder = new FirefoxRelayForwarder(null, null, null, null, null); @@ -15,6 +20,16 @@ describe("Firefox Relay Forwarder", () => { expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new FirefoxRelayForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultFirefoxRelayOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(200, {}); diff --git a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts index 1308852224c..a4122c53f8f 100644 --- a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts +++ b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts @@ -1,13 +1,21 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; +export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({ + website: null, + token: "", +}); + /** Generates a forwarding address for Firefox Relay */ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { /** Instantiates the forwarder @@ -32,6 +40,11 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { + return new BehaviorSubject({ ...DefaultFirefoxRelayOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions) => { if (!options.token || options.token === "") { @@ -75,3 +88,8 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { it("key returns the Forward Email forwarder key", () => { const forwarder = new ForwardEmailForwarder(null, null, null, null, null); @@ -15,6 +20,16 @@ describe("ForwardEmail Forwarder", () => { expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new ForwardEmailForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultForwardEmailOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(200, {}); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.ts index eb6e3cd0c67..93f4680414a 100644 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.ts +++ b/libs/common/src/tools/generator/username/forwarders/forward-email.ts @@ -1,14 +1,23 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { Utils } from "../../../../platform/misc/utils"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options"; +export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({ + website: null, + token: "", + domain: "", +}); + /** Generates a forwarding address for Forward Email */ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< ApiOptions & EmailDomainOptions @@ -35,6 +44,11 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< return FORWARD_EMAIL_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.defaults$} */ + defaults$ = (userId: UserId) => { + return new BehaviorSubject({ ...DefaultForwardEmailOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { @@ -96,3 +110,9 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< } }; } + +export const DefaultOptions = Object.freeze({ + website: null, + token: "", + domain: "", +}); diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts index 1120d49ce31..c53e7832706 100644 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts +++ b/libs/common/src/tools/generator/username/forwarders/simple-login.spec.ts @@ -2,11 +2,16 @@ * include Request in test environment. * @jest-environment ../../../../shared/test.environment.ts */ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "../../../../types/guid"; import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions"; import { Forwarders } from "../options/constants"; import { mockApiService, mockI18nService } from "./mocks.jest"; -import { SimpleLoginForwarder } from "./simple-login"; +import { SimpleLoginForwarder, DefaultSimpleLoginOptions } from "./simple-login"; + +const SomeUser = "some user" as UserId; describe("SimpleLogin Forwarder", () => { it("key returns the Simple Login forwarder key", () => { @@ -15,6 +20,16 @@ describe("SimpleLogin Forwarder", () => { expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new SimpleLoginForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultSimpleLoginOptions); + }); + }); + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { const apiService = mockApiService(200, {}); diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.ts index 33bd8e3d4e0..d047fc42d14 100644 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.ts +++ b/libs/common/src/tools/generator/username/forwarders/simple-login.ts @@ -1,13 +1,22 @@ +import { BehaviorSubject } from "rxjs"; + import { ApiService } from "../../../../abstractions/api.service"; import { CryptoService } from "../../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; +import { UserId } from "../../../../types/guid"; import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { SelfHostedApiOptions } from "../options/forwarder-options"; +export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({ + website: null, + baseUrl: "https://app.simplelogin.io", + token: "", +}); + /** Generates a forwarding address for Simple Login */ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { /** Instantiates the forwarder @@ -32,6 +41,11 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { + return new BehaviorSubject({ ...DefaultSimpleLoginOptions }); + }; + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: SelfHostedApiOptions) => { if (!options.token || options.token === "") { @@ -80,3 +94,9 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { - describe("forAllForwarders", () => { - it("runs the function on every forwarder.", () => { - const result = forAllForwarders(TestOptions, (_, id) => id); - expect(result).toEqual([ - "anonaddy", - "duckduckgo", - "fastmail", - "firefoxrelay", - "forwardemail", - "simplelogin", - ]); - }); - }); - - describe("getForwarderOptions", () => { - it("should return null for unsupported services", () => { - expect(getForwarderOptions("unsupported", DefaultOptions)).toBeNull(); - }); - - let options: UsernameGeneratorOptions = null; - beforeEach(() => { - options = structuredClone(TestOptions); - }); - - it.each([ - [TestOptions.forwarders.addyIo, "anonaddy"], - [TestOptions.forwarders.duckDuckGo, "duckduckgo"], - [TestOptions.forwarders.fastMail, "fastmail"], - [TestOptions.forwarders.firefoxRelay, "firefoxrelay"], - [TestOptions.forwarders.forwardEmail, "forwardemail"], - [TestOptions.forwarders.simpleLogin, "simplelogin"], - ])("should return an %s for %p", (forwarderOptions, service) => { - const forwarder = getForwarderOptions(service, options); - expect(forwarder).toEqual(forwarderOptions); - }); - - it("should return a reference to the forwarder", () => { - const forwarder = getForwarderOptions("anonaddy", options); - expect(forwarder).toBe(options.forwarders.addyIo); - }); - }); - - describe("falsyDefault", () => { - it("should not modify values with truthy items", () => { - const input = { - a: "a", - b: 1, - d: [1], - }; - - const output = falsyDefault(input, { - a: "b", - b: 2, - d: [2], - }); - - expect(output).toEqual(input); - }); - - it("should modify values with falsy items", () => { - const input = { - a: "", - b: 0, - c: false, - d: [] as number[], - e: [0] as number[], - f: null as string, - g: undefined as string, - }; - - const output = falsyDefault(input, { - a: "a", - b: 1, - c: true, - d: [1], - e: [1], - f: "a", - g: "a", - }); - - expect(output).toEqual({ - a: "a", - b: 1, - c: true, - d: [1], - e: [1], - f: "a", - g: "a", - }); - }); - - it("should traverse nested objects", () => { - const input = { - a: { - b: { - c: "", - }, - }, - }; - - const output = falsyDefault(input, { - a: { - b: { - c: "c", - }, - }, - }); - - expect(output).toEqual({ - a: { - b: { - c: "c", - }, - }, - }); - }); - - it("should add missing defaults", () => { - const input = {}; - - const output = falsyDefault(input, { - a: "a", - b: [1], - c: {}, - d: { e: 1 }, - }); - - expect(output).toEqual({ - a: "a", - b: [1], - c: {}, - d: { e: 1 }, - }); - }); - - it("should ignore missing defaults", () => { - const input = { - a: "", - b: 0, - c: false, - d: [] as number[], - e: [0] as number[], - f: null as string, - g: undefined as string, - }; - - const output = falsyDefault(input, {}); - - expect(output).toEqual({ - a: "", - b: 0, - c: false, - d: [] as number[], - e: [0] as number[], - f: null as string, - g: undefined as string, - }); - }); - - it.each([[null], [undefined]])("should ignore %p defaults", (defaults) => { - const input = { - a: "", - b: 0, - c: false, - d: [] as number[], - e: [0] as number[], - f: null as string, - g: undefined as string, - }; - - const output = falsyDefault(input, defaults); - - expect(output).toEqual({ - a: "", - b: 0, - c: false, - d: [] as number[], - e: [0] as number[], - f: null as string, - g: undefined as string, - }); - }); - }); -}); diff --git a/libs/common/src/tools/generator/username/options/utilities.ts b/libs/common/src/tools/generator/username/options/utilities.ts deleted file mode 100644 index ba0c6c291f1..00000000000 --- a/libs/common/src/tools/generator/username/options/utilities.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { DefaultOptions, Forwarders } from "./constants"; -import { ApiOptions, ForwarderId } from "./forwarder-options"; -import { MaybeLeakedOptions, UsernameGeneratorOptions } from "./generator-options"; - -/** runs the callback on each forwarder configuration */ -export function forAllForwarders( - options: UsernameGeneratorOptions, - callback: (options: ApiOptions, id: ForwarderId) => T, -) { - const results = []; - for (const forwarder of Object.values(Forwarders).map((f) => f.id)) { - const forwarderOptions = getForwarderOptions(forwarder, options); - if (forwarderOptions) { - results.push(callback(forwarderOptions, forwarder)); - } - } - return results; -} - -/** Gets the options for the specified forwarding service with defaults applied. - * This method mutates `options`. - * @param service Identifies the service whose options should be loaded. - * @param options The options to load from. - * @returns A reference to the options for the specified service. - */ -export function getForwarderOptions( - service: string, - options: UsernameGeneratorOptions, -): ApiOptions & MaybeLeakedOptions { - if (service === Forwarders.AddyIo.id) { - return falsyDefault(options.forwarders.addyIo, DefaultOptions.forwarders.addyIo); - } else if (service === Forwarders.DuckDuckGo.id) { - return falsyDefault(options.forwarders.duckDuckGo, DefaultOptions.forwarders.duckDuckGo); - } else if (service === Forwarders.Fastmail.id) { - return falsyDefault(options.forwarders.fastMail, DefaultOptions.forwarders.fastMail); - } else if (service === Forwarders.FirefoxRelay.id) { - return falsyDefault(options.forwarders.firefoxRelay, DefaultOptions.forwarders.firefoxRelay); - } else if (service === Forwarders.ForwardEmail.id) { - return falsyDefault(options.forwarders.forwardEmail, DefaultOptions.forwarders.forwardEmail); - } else if (service === Forwarders.SimpleLogin.id) { - return falsyDefault(options.forwarders.simpleLogin, DefaultOptions.forwarders.simpleLogin); - } else { - return null; - } -} - -/** - * Recursively applies default values from `defaults` to falsy or - * missing properties in `value`. - * - * @remarks This method is not aware of the - * object's prototype or metadata, such as readonly or frozen fields. - * It should only be used on plain objects. - * - * @param value - The value to fill in. This parameter is mutated. - * @param defaults - The default values to use. - * @returns the mutated `value`. - */ -export function falsyDefault(value: T, defaults: Partial): T { - // iterate keys in defaults because `value` may be missing keys - for (const key in defaults) { - if (defaults[key] instanceof Object) { - // `any` type is required because typescript can't predict the type of `value[key]`. - const target: any = value[key] || (defaults[key] instanceof Array ? [] : {}); - value[key] = falsyDefault(target, defaults[key]); - } else if (!value[key]) { - value[key] = defaults[key]; - } - } - - return value; -} diff --git a/libs/common/src/tools/generator/username/subaddress-generator-options.ts b/libs/common/src/tools/generator/username/subaddress-generator-options.ts index a43b8798edb..dc38b2a6ea0 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-options.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-options.ts @@ -1,10 +1,18 @@ +import { RequestOptions } from "./options/forwarder-options"; +import { UsernameGenerationMode } from "./options/generator-options"; + /** Settings supported when generating an email subaddress */ export type SubaddressGenerationOptions = { - type?: "random" | "website-name"; - email?: string; -}; + /** selects the generation algorithm for the catchall email address. */ + subaddressType?: UsernameGenerationMode; + + /** the email address the subaddress is applied to. */ + subaddressEmail?: string; +} & RequestOptions; /** The default options for email subaddress generation. */ -export const DefaultSubaddressOptions: Partial = Object.freeze({ - type: "random", +export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({ + subaddressType: "random", + subaddressEmail: "", + website: null, }); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index 59a2b56172a..827bc7aed0d 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -10,6 +10,11 @@ import { UserId } from "../../../types/guid"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { SUBADDRESS_SETTINGS } from "../key-definitions"; +import { + DefaultSubaddressOptions, + SubaddressGenerationOptions, +} from "./subaddress-generator-options"; + import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; @@ -47,6 +52,16 @@ describe("Email subaddress list generation strategy", () => { }); }); + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new SubaddressGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultSubaddressOptions); + }); + }); + describe("cache_ms", () => { it("should be a positive non-zero number", () => { const legacy = mock(); @@ -70,16 +85,14 @@ describe("Email subaddress list generation strategy", () => { const legacy = mock(); const strategy = new SubaddressGeneratorStrategy(legacy, null); const options = { - type: "website-name" as const, - email: "someone@example.com", - }; + subaddressType: "website-name", + subaddressEmail: "someone@example.com", + website: "foo.com", + } as SubaddressGenerationOptions; await strategy.generate(options); - expect(legacy.generateSubaddress).toHaveBeenCalledWith({ - subaddressType: "website-name" as const, - subaddressEmail: "someone@example.com", - }); + expect(legacy.generateSubaddress).toHaveBeenCalledWith(options); }); }); }); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 1ae0cb91427..818741f8a9b 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -1,19 +1,26 @@ -import { map, pipe } from "rxjs"; +import { BehaviorSubject, map, pipe } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; +import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { NoPolicy } from "../no-policy"; -import { SubaddressGenerationOptions } from "./subaddress-generator-options"; -import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; +import { + DefaultSubaddressOptions, + SubaddressGenerationOptions, +} from "./subaddress-generator-options"; const ONE_MINUTE = 60 * 1000; -/** Strategy for creating an email subaddress */ +/** Strategy for creating an email subaddress + * @remarks The subaddress is the part following the `+`. + * For example, if the email address is `jd+xyz@domain.io`, + * the subaddress is `xyz`. + */ export class SubaddressGeneratorStrategy implements GeneratorStrategy { @@ -30,6 +37,11 @@ export class SubaddressGeneratorStrategy return this.stateProvider.getUser(id, SUBADDRESS_SETTINGS); } + /** {@link GeneratorStrategy.defaults$} */ + defaults$(userId: UserId) { + return new BehaviorSubject({ ...DefaultSubaddressOptions }).asObservable(); + } + /** {@link GeneratorStrategy.policy} */ get policy() { // Uses password generator since there aren't policies @@ -49,9 +61,6 @@ export class SubaddressGeneratorStrategy /** {@link GeneratorStrategy.generate} */ generate(options: SubaddressGenerationOptions) { - return this.usernameService.generateSubaddress({ - subaddressEmail: options.email, - subaddressType: options.type, - }); + return this.usernameService.generateSubaddress(options); } } diff --git a/libs/common/src/tools/generator/username/username-generation-options.ts b/libs/common/src/tools/generator/username/username-generation-options.ts index 2cb1e8dfd66..b52b4c0848b 100644 --- a/libs/common/src/tools/generator/username/username-generation-options.ts +++ b/libs/common/src/tools/generator/username/username-generation-options.ts @@ -1,21 +1,23 @@ +import { CatchallGenerationOptions } from "./catchall-generator-options"; import { EffUsernameGenerationOptions } from "./eff-username-generator-options"; +import { ForwarderId, RequestOptions } from "./options/forwarder-options"; +import { UsernameGeneratorType } from "./options/generator-options"; +import { SubaddressGenerationOptions } from "./subaddress-generator-options"; -export type UsernameGeneratorOptions = EffUsernameGenerationOptions & { - type?: "word" | "subaddress" | "catchall" | "forwarded"; - subaddressType?: "random" | "website-name"; - subaddressEmail?: string; - catchallType?: "random" | "website-name"; - catchallDomain?: string; - website?: string; - forwardedService?: string; - forwardedAnonAddyApiToken?: string; - forwardedAnonAddyDomain?: string; - forwardedAnonAddyBaseUrl?: string; - forwardedDuckDuckGoToken?: string; - forwardedFirefoxApiToken?: string; - forwardedFastmailApiToken?: string; - forwardedForwardEmailApiToken?: string; - forwardedForwardEmailDomain?: string; - forwardedSimpleLoginApiKey?: string; - forwardedSimpleLoginBaseUrl?: string; -}; +export type UsernameGeneratorOptions = EffUsernameGenerationOptions & + SubaddressGenerationOptions & + CatchallGenerationOptions & + RequestOptions & { + type?: UsernameGeneratorType; + forwardedService?: ForwarderId | ""; + forwardedAnonAddyApiToken?: string; + forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; + forwardedDuckDuckGoToken?: string; + forwardedFirefoxApiToken?: string; + forwardedFastmailApiToken?: string; + forwardedForwardEmailApiToken?: string; + forwardedForwardEmailDomain?: string; + forwardedSimpleLoginApiKey?: string; + forwardedSimpleLoginBaseUrl?: string; + }; diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index 245e7575b7a..1ee642da5eb 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -2,6 +2,7 @@ import { ApiService } from "../../../abstractions/api.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { EFFLongWordList } from "../../../platform/misc/wordlist"; +import { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction"; import { AnonAddyForwarder, @@ -14,10 +15,10 @@ import { SimpleLoginForwarder, } from "./email-forwarders"; import { UsernameGeneratorOptions } from "./username-generation-options"; -import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; const DefaultOptions: UsernameGeneratorOptions = { type: "word", + website: null, wordCapitalize: true, wordIncludeNumber: true, subaddressType: "random", diff --git a/libs/common/src/vault/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts index 74d369380bf..86766bdeac6 100644 --- a/libs/common/src/vault/models/view/collection.view.ts +++ b/libs/common/src/vault/models/view/collection.view.ts @@ -53,11 +53,11 @@ export class CollectionView implements View, ITreeNodeObject { ); } - return org?.canEditAnyCollection || (org?.canEditAssignedCollections && this.assigned); + return org?.canEditAnyCollection(false) || (org?.canEditAssignedCollections && this.assigned); } // For editing collection details, not the items within it. - canEdit(org: Organization): boolean { + canEdit(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { if (org != null && org.id !== this.organizationId) { throw new Error( "Id of the organization provided does not match the org id of the collection.", @@ -65,8 +65,8 @@ export class CollectionView implements View, ITreeNodeObject { } return org?.flexibleCollections - ? org?.canEditAnyCollection || this.manage - : org?.canEditAnyCollection || org?.canEditAssignedCollections; + ? org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || this.manage + : org?.canEditAnyCollection(flexibleCollectionsV1Enabled) || org?.canEditAssignedCollections; } // For deleting a collection, not the items within it. diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 429e9fc0c69..b19506952d9 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -40,7 +40,7 @@ export class AvatarComponent implements OnChanges { get classList() { return ["tw-rounded-full"] .concat(SizeClasses[this.size] ?? []) - .concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-500"] : []); + .concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []); } private generate() { diff --git a/libs/components/src/avatar/avatar.mdx b/libs/components/src/avatar/avatar.mdx index c6c5ff78ba4..0f3f6f06a9b 100644 --- a/libs/components/src/avatar/avatar.mdx +++ b/libs/components/src/avatar/avatar.mdx @@ -44,7 +44,7 @@ Use the user 'ID' field if `Name` is not defined. ## Outline If the avatar is displayed on one of the theme's `background` color variables or is interactive, -display the avatar with a 1 pixel `secondary-500` border to meet WCAG AA graphic contrast guidelines +display the avatar with a 1 pixel `secondary-600` border to meet WCAG AA graphic contrast guidelines for interactive elements. @@ -64,4 +64,4 @@ When the avatar is used as a button, the following states should be used: ## Accessibility Avatar background color should have 3.1:1 contrast with it’s background; or include the -`secondary-500` border Avatar text should have 4.5:1 contrast with the avatar background color +`secondary-600` border Avatar text should have 4.5:1 contrast with the avatar background color diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts index dd28d86ae86..b81b9f80e27 100644 --- a/libs/components/src/badge/badge.directive.ts +++ b/libs/components/src/badge/badge.directive.ts @@ -3,12 +3,12 @@ import { Directive, ElementRef, HostBinding, Input } from "@angular/core"; export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info"; const styles: Record = { - primary: ["tw-bg-primary-500"], + primary: ["tw-bg-primary-600"], secondary: ["tw-bg-text-muted"], - success: ["tw-bg-success-500"], - danger: ["tw-bg-danger-500"], - warning: ["tw-bg-warning-500"], - info: ["tw-bg-info-500"], + success: ["tw-bg-success-600"], + danger: ["tw-bg-danger-600"], + warning: ["tw-bg-warning-600"], + info: ["tw-bg-info-600"], }; const hoverStyles: Record = { diff --git a/libs/components/src/banner/banner.component.ts b/libs/components/src/banner/banner.component.ts index e93bcb22142..099fa11fa4d 100644 --- a/libs/components/src/banner/banner.component.ts +++ b/libs/components/src/banner/banner.component.ts @@ -28,13 +28,13 @@ export class BannerComponent implements OnInit { get bannerClass() { switch (this.bannerType) { case "danger": - return "tw-bg-danger-500"; + return "tw-bg-danger-600"; case "info": - return "tw-bg-info-500"; + return "tw-bg-info-600"; case "premium": - return "tw-bg-success-500"; + return "tw-bg-success-600"; case "warning": - return "tw-bg-warning-500"; + return "tw-bg-warning-600"; } } } diff --git a/libs/components/src/button/button.component.spec.ts b/libs/components/src/button/button.component.spec.ts index a0b9c1e7a56..a75ac400a96 100644 --- a/libs/components/src/button/button.component.spec.ts +++ b/libs/components/src/button/button.component.spec.ts @@ -30,8 +30,8 @@ describe("Button", () => { it("should apply classes based on type", () => { testAppComponent.buttonType = "primary"; fixture.detectChanges(); - expect(buttonDebugElement.nativeElement.classList.contains("tw-bg-primary-500")).toBe(true); - expect(linkDebugElement.nativeElement.classList.contains("tw-bg-primary-500")).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains("tw-bg-primary-600")).toBe(true); + expect(linkDebugElement.nativeElement.classList.contains("tw-bg-primary-600")).toBe(true); testAppComponent.buttonType = "secondary"; fixture.detectChanges(); @@ -40,8 +40,8 @@ describe("Button", () => { testAppComponent.buttonType = "danger"; fixture.detectChanges(); - expect(buttonDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true); - expect(linkDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true); + expect(buttonDebugElement.nativeElement.classList.contains("tw-border-danger-600")).toBe(true); + expect(linkDebugElement.nativeElement.classList.contains("tw-border-danger-600")).toBe(true); testAppComponent.buttonType = "unstyled"; fixture.detectChanges(); diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 414d4e59138..3cbacb4731a 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -12,13 +12,13 @@ const focusRing = [ const buttonStyles: Record = { primary: [ - "tw-border-primary-500", - "tw-bg-primary-500", + "tw-border-primary-600", + "tw-bg-primary-600", "!tw-text-contrast", "hover:tw-bg-primary-700", "hover:tw-border-primary-700", - "disabled:tw-bg-primary-500/60", - "disabled:tw-border-primary-500/60", + "disabled:tw-bg-primary-600/60", + "disabled:tw-border-primary-600/60", "disabled:!tw-text-contrast/60", "disabled:tw-bg-clip-padding", "disabled:tw-cursor-not-allowed", @@ -39,13 +39,13 @@ const buttonStyles: Record = { ], danger: [ "tw-bg-transparent", - "tw-border-danger-500", + "tw-border-danger-600", "!tw-text-danger", - "hover:tw-bg-danger-500", - "hover:tw-border-danger-500", + "hover:tw-bg-danger-600", + "hover:tw-border-danger-600", "hover:!tw-text-contrast", "disabled:tw-bg-transparent", - "disabled:tw-border-danger-500/60", + "disabled:tw-border-danger-600/60", "disabled:!tw-text-danger/60", "disabled:tw-cursor-not-allowed", ...focusRing, diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 7ce79071bf0..6942d4bc15a 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -42,13 +42,13 @@ export class CalloutComponent implements OnInit { get calloutClass() { switch (this.type) { case "danger": - return "tw-border-l-danger-500"; + return "tw-border-l-danger-600"; case "info": - return "tw-border-l-info-500"; + return "tw-border-l-info-600"; case "success": - return "tw-border-l-success-500"; + return "tw-border-l-success-600"; case "warning": - return "tw-border-l-warning-500"; + return "tw-border-l-warning-600"; } } diff --git a/libs/components/src/checkbox/checkbox.component.ts b/libs/components/src/checkbox/checkbox.component.ts index bbc288659c6..d8fd3f76eaa 100644 --- a/libs/components/src/checkbox/checkbox.component.ts +++ b/libs/components/src/checkbox/checkbox.component.ts @@ -20,7 +20,7 @@ export class CheckboxComponent implements BitFormControlAbstraction { "tw-rounded", "tw-border", "tw-border-solid", - "tw-border-secondary-500", + "tw-border-secondary-600", "tw-h-3.5", "tw-w-3.5", "tw-mr-1.5", @@ -43,8 +43,8 @@ export class CheckboxComponent implements BitFormControlAbstraction { "disabled:tw-border", "disabled:tw-bg-secondary-100", - "checked:tw-bg-primary-500", - "checked:tw-border-primary-500", + "checked:tw-bg-primary-600", + "checked:tw-border-primary-600", "checked:hover:tw-bg-primary-700", "checked:hover:tw-border-primary-700", "[&>label:hover]:checked:tw-bg-primary-700", @@ -59,8 +59,8 @@ export class CheckboxComponent implements BitFormControlAbstraction { "[&:not(:indeterminate)]:checked:before:tw-mask-image-[var(--mask-image)]", "indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]", - "indeterminate:tw-bg-primary-500", - "indeterminate:tw-border-primary-500", + "indeterminate:tw-bg-primary-600", + "indeterminate:tw-border-primary-600", "indeterminate:hover:tw-bg-primary-700", "indeterminate:hover:tw-border-primary-700", "[&>label:hover]:indeterminate:tw-bg-primary-700", diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index 4c32d0af0d1..efa2ab687f5 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -30,7 +30,7 @@ export class ColorPasswordComponent { [CharacterType.Emoji]: [], [CharacterType.Letter]: ["tw-text-main"], [CharacterType.Special]: ["tw-text-danger"], - [CharacterType.Number]: ["tw-text-primary-500"], + [CharacterType.Number]: ["tw-text-primary-600"], }; @HostBinding("class") diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts index e93498ec48b..1438c7926e9 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts @@ -15,7 +15,7 @@ const DEFAULT_ICON: Record = { }; const DEFAULT_COLOR: Record = { - primary: "tw-text-primary-500", + primary: "tw-text-primary-600", success: "tw-text-success", info: "tw-text-info", warning: "tw-text-warning", diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx index 5e45b7bef6e..a78ba4650a7 100644 --- a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -33,11 +33,11 @@ the simple dialog's type is specified. | type | icon name | icon | color | | ------- | ------------------------ | -------------------------------------------- | ----------- | -| primary | bwi-business | | primary-500 | -| success | bwi-star | | success-500 | -| info | bwi-info-circle | | info-500 | -| warning | bwi-exclamation-triangle | | warning-500 | -| danger | bwi-error | | danger-500 | +| primary | bwi-business | | primary-600 | +| success | bwi-star | | success-600 | +| info | bwi-info-circle | | info-600 | +| warning | bwi-exclamation-triangle | | warning-600 | +| danger | bwi-error | | danger-600 | ## Scrolling Content diff --git a/libs/components/src/form-field/prefix.directive.ts b/libs/components/src/form-field/prefix.directive.ts index 62643c8bb73..6e1e15fd207 100644 --- a/libs/components/src/form-field/prefix.directive.ts +++ b/libs/components/src/form-field/prefix.directive.ts @@ -6,7 +6,7 @@ export const PrefixClasses = [ "tw-bg-background-alt", "tw-border", "tw-border-solid", - "tw-border-secondary-500", + "tw-border-secondary-600", "tw-text-muted", "tw-rounded-none", ]; diff --git a/libs/components/src/form/forms.mdx b/libs/components/src/form/forms.mdx index 08451565611..a42ddccbe60 100644 --- a/libs/components/src/form/forms.mdx +++ b/libs/components/src/form/forms.mdx @@ -174,5 +174,5 @@ the field’s label. - All field inputs are interactive elements that must follow the WCAG graphic contrast guidelines. Maintain a ratio of 3:1 with the form's background. -- Error styling should not rely only on using the `danger-500`color change. Use +- Error styling should not rely only on using the `danger-600`color change. Use as a prefix to highlight the text as error text versus helper diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 73872926f8c..53e80327956 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -60,15 +60,15 @@ const styles: Record = { ...focusRing, ], primary: [ - "tw-bg-primary-500", + "tw-bg-primary-600", "!tw-text-contrast", - "tw-border-primary-500", + "tw-border-primary-600", "hover:tw-bg-primary-700", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", - "disabled:hover:tw-border-primary-500", - "disabled:hover:tw-bg-primary-500", + "disabled:hover:tw-border-primary-600", + "disabled:hover:tw-bg-primary-600", ...focusRing, ], secondary: [ @@ -88,15 +88,15 @@ const styles: Record = { danger: [ "tw-bg-transparent", "!tw-text-danger", - "tw-border-danger-500", + "tw-border-danger-600", "hover:!tw-text-contrast", - "hover:tw-bg-danger-500", + "hover:tw-bg-danger-600", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", - "disabled:hover:tw-border-danger-500", + "disabled:hover:tw-border-danger-600", "disabled:hover:tw-bg-transparent", "disabled:hover:!tw-text-danger", - "disabled:hover:tw-border-danger-500", + "disabled:hover:tw-border-danger-600", ...focusRing, ], light: [ diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 19bc972a70f..0f25d2de583 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -30,7 +30,7 @@ export const Default: Story = { -
+
@@ -111,7 +111,7 @@ export const Contrast: Story = { render: (args) => ({ props: args, template: ` -
+
`, diff --git a/libs/components/src/icon/icons/no-access.ts b/libs/components/src/icon/icons/no-access.ts index f9ad048752a..1011b3089cf 100644 --- a/libs/components/src/icon/icons/no-access.ts +++ b/libs/components/src/icon/icons/no-access.ts @@ -2,11 +2,11 @@ import { svgIcon } from "../icon"; export const NoAccess = svgIcon` - - - - - - + + + + + + `; diff --git a/libs/components/src/icon/icons/search.ts b/libs/components/src/icon/icons/search.ts index de41dd3b190..914fa0e9813 100644 --- a/libs/components/src/icon/icons/search.ts +++ b/libs/components/src/icon/icons/search.ts @@ -4,15 +4,15 @@ export const Search = svgIcon` - - - - - - - - - + + + + + + + + + `; diff --git a/libs/components/src/input/input.directive.ts b/libs/components/src/input/input.directive.ts index 9bd110704e6..27c7d8175d1 100644 --- a/libs/components/src/input/input.directive.ts +++ b/libs/components/src/input/input.directive.ts @@ -29,7 +29,7 @@ export class BitInputDirective implements BitFormFieldControl { "tw-bg-background-alt", "tw-border", "tw-border-solid", - this.hasError ? "tw-border-danger-500" : "tw-border-secondary-500", + this.hasError ? "tw-border-danger-600" : "tw-border-secondary-600", "tw-text-main", "tw-placeholder-text-muted", // Rounded diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index 6d923acf3d5..a8ee528da1e 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -4,10 +4,10 @@ export type LinkType = "primary" | "secondary" | "contrast" | "light"; const linkStyles: Record = { primary: [ - "!tw-text-primary-500", - "hover:!tw-text-primary-500", + "!tw-text-primary-600", + "hover:!tw-text-primary-600", "focus-visible:before:tw-ring-primary-700", - "disabled:!tw-text-primary-500/60", + "disabled:!tw-text-primary-600/60", ], secondary: [ "!tw-text-main", diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index 48c8c2abd5e..100824277a5 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -6,7 +6,7 @@ import * as stories from "./link.stories"; # Link / Text button -Text Links and Buttons use the `primary-500` color and can use either the `` or `
@@ -61,7 +61,7 @@ export const Anchors: StoryObj = { render: (args) => ({ props: args, template: ` -
+
@@ -108,7 +108,7 @@ export const Disabled: Story = { template: ` -
+
`, diff --git a/libs/components/src/menu/menu-divider.component.html b/libs/components/src/menu/menu-divider.component.html index 98048261cff..7d9fee3e8f2 100644 --- a/libs/components/src/menu/menu-divider.component.html +++ b/libs/components/src/menu/menu-divider.component.html @@ -1,5 +1,5 @@ diff --git a/libs/components/src/menu/menu-item.directive.ts b/libs/components/src/menu/menu-item.directive.ts index 2a50dd366fc..77246bbcdff 100644 --- a/libs/components/src/menu/menu-item.directive.ts +++ b/libs/components/src/menu/menu-item.directive.ts @@ -16,12 +16,12 @@ export class MenuItemDirective implements FocusableOption { "tw-bg-background", "tw-text-left", "hover:tw-bg-secondary-100", - "focus:tw-bg-secondary-100", - "focus:tw-z-50", - "focus:tw-outline-none", - "focus:tw-ring", - "focus:tw-ring-offset-2", - "focus:tw-ring-primary-700", + "focus-visible:tw-bg-secondary-100", + "focus-visible:tw-z-50", + "focus-visible:tw-outline-none", + "focus-visible:tw-ring", + "focus-visible:tw-ring-offset-2", + "focus-visible:tw-ring-primary-700", "active:!tw-ring-0", "active:!tw-ring-offset-0", ]; diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 7e392f241f0..05f2e7a8ef1 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -88,12 +88,12 @@ export class MenuTriggerForDirective implements OnDestroy { } this.destroyMenu(); }); - this.menu.keyManager.setFirstItemActive(); - this.keyDownEventsSub = - this.menu.keyManager && - this.overlayRef + if (this.menu.keyManager) { + this.menu.keyManager.setFirstItemActive(); + this.keyDownEventsSub = this.overlayRef .keydownEvents() .subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event)); + } } private destroyMenu() { diff --git a/libs/components/src/menu/menu.component.html b/libs/components/src/menu/menu.component.html index 98a35e97de8..5b6b15b5cbe 100644 --- a/libs/components/src/menu/menu.component.html +++ b/libs/components/src/menu/menu.component.html @@ -1,7 +1,7 @@