1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 21:50:15 +00:00

Merge branch 'main' into zhHantExtraSupport

This commit is contained in:
nickcan0120
2025-02-05 08:21:55 +08:00
committed by GitHub
197 changed files with 5373 additions and 8250 deletions

4
.github/CODEOWNERS vendored
View File

@@ -97,12 +97,15 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev
.github/workflows/scan.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
.github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev
# ESLint custom rules
libs/eslint @bitwarden/team-platform-dev
## Autofill team files ##
apps/browser/src/autofill @bitwarden/team-autofill-dev
apps/desktop/src/autofill @bitwarden/team-autofill-dev
libs/common/src/autofill @bitwarden/team-autofill-dev
apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev
apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev
@@ -128,6 +131,7 @@ apps/web/src/app/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
apps/desktop/destkop_native/core/src/biometric/ @bitwarden/team-key-management-dev

View File

@@ -211,6 +211,8 @@
"@storybook/angular",
"@storybook/manager-api",
"@storybook/theming",
"@typescript-eslint/utils",
"@typescript-eslint/rule-tester",
"@types/react",
"autoprefixer",
"bootstrap",

View File

@@ -88,7 +88,7 @@ jobs:
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
- name: Upload results to codecov.io
uses: codecov/test-results-action@9739113ad922ea0a9abb4b2c0f8bf6a4aa8ef820 # v1.0.1
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -103,15 +103,15 @@ jobs:
matrix:
os:
- ubuntu-22.04
- macos-latest
- windows-latest
- macos-14
- windows-2022
steps:
- name: Check Rust version
run: rustup --version
- name: Install gnome-keyring
if: ${{ matrix.os=='ubuntu-latest' }}
if: ${{ matrix.os=='ubuntu-22.04' }}
run: |
sudo apt-get update
sudo apt-get install -y gnome-keyring dbus-x11
@@ -124,7 +124,7 @@ jobs:
run: cargo build
- name: Test Ubuntu
if: ${{ matrix.os=='ubuntu-latest' }}
if: ${{ matrix.os=='ubuntu-22.04' }}
working-directory: ./apps/desktop/desktop_native
run: |
eval "$(dbus-launch --sh-syntax)"
@@ -135,11 +135,41 @@ jobs:
cargo test -- --test-threads=1
- name: Test macOS
if: ${{ matrix.os=='macos-latest' }}
if: ${{ matrix.os=='macos-14' }}
working-directory: ./apps/desktop/desktop_native
run: cargo test -- --test-threads=1
- name: Test Windows
if: ${{ matrix.os=='windows-latest'}}
if: ${{ matrix.os=='windows-2022'}}
working-directory: ./apps/desktop/desktop_native/core
run: cargo test -- --test-threads=1
rust-coverage:
name: Rust Coverage
runs-on: macos-14
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install rust
uses: dtolnay/rust-toolchain@a54c7afa936fefeb4456b2dd8068152669aa8203 # stable
with:
toolchain: stable
components: llvm-tools
- name: Cache cargo registry
uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2.7.5
with:
workspaces: "apps/desktop/desktop_native -> target"
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --version 0.6.16
- name: Generate coverage
working-directory: ./apps/desktop/desktop_native
run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage
- name: Upload to codecov.io
uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1
with:
files: ./apps/desktop/desktop_native/lcov.info

View File

@@ -4155,15 +4155,6 @@
"itemName": {
"message": "Item name"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"organizationIsDeactivated": {
"message": "Organization is deactivated"
},
@@ -4896,6 +4887,15 @@
"extraWide": {
"message": "Extra wide"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"updateDesktopAppOrDisableFingerprintDialogTitle": {
"message": "Please update your desktop application"
},

View File

@@ -424,6 +424,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
await this.setupSubmitListenerOnFormlessField(formFieldElement);
return;
}
/**
@@ -439,15 +440,16 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
this.formElements.add(formElement);
formElement.addEventListener(EVENTS.SUBMIT, this.handleFormFieldSubmitEvent);
const closesSubmitButton = await this.findSubmitButton(formElement);
const closestSubmitButton = await this.findSubmitButton(formElement);
// If we cannot find a submit button within the form, check for a submit button outside the form.
if (!closesSubmitButton) {
if (!closestSubmitButton) {
await this.setupSubmitListenerOnFormlessField(formFieldElement);
return;
}
this.setupSubmitButtonEventListeners(closesSubmitButton);
this.setupSubmitButtonEventListeners(closestSubmitButton);
return;
}
}
@@ -459,9 +461,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
*/
private async setupSubmitListenerOnFormlessField(formFieldElement: FillableFormFieldElement) {
if (formFieldElement && !this.fieldsWithSubmitElements.has(formFieldElement)) {
const closesSubmitButton = await this.findClosestFormlessSubmitButton(formFieldElement);
this.setupSubmitButtonEventListeners(closesSubmitButton);
const closestSubmitButton = await this.findClosestFormlessSubmitButton(formFieldElement);
this.setupSubmitButtonEventListeners(closestSubmitButton);
}
return;
}
/**

View File

@@ -747,7 +747,7 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "generateFillScript");
jest.spyOn(autofillService as any, "generateLoginFillScript");
jest.spyOn(logService, "info");
jest.spyOn(cipherService, "updateLastUsedDate");
jest.spyOn(chrome.runtime, "sendMessage");
jest.spyOn(eventCollectionService, "collect");
const autofillResult = await autofillService.doAutoFill(autofillOptions);
@@ -769,7 +769,10 @@ describe("AutofillService", () => {
);
expect(autofillService["generateLoginFillScript"]).toHaveBeenCalled();
expect(logService.info).not.toHaveBeenCalled();
expect(cipherService.updateLastUsedDate).toHaveBeenCalledWith(autofillOptions.cipher.id);
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
cipherId: autofillOptions.cipher.id,
command: "updateLastUsedDate",
});
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
autofillOptions.pageDetails[0].tab.id,
{
@@ -890,11 +893,11 @@ describe("AutofillService", () => {
it("skips updating the cipher's last used date if the passed options indicate that we should skip the last used cipher", async () => {
autofillOptions.skipLastUsed = true;
jest.spyOn(cipherService, "updateLastUsedDate");
jest.spyOn(chrome.runtime, "sendMessage");
await autofillService.doAutoFill(autofillOptions);
expect(cipherService.updateLastUsedDate).not.toHaveBeenCalled();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
});
it("returns early if the fillScript cannot be generated", async () => {

View File

@@ -463,8 +463,13 @@ export default class AutofillService implements AutofillServiceInterface {
fillScript.properties.delay_between_operations = 20;
didAutofill = true;
if (!options.skipLastUsed) {
await this.cipherService.updateLastUsedDate(options.cipher.id);
// In order to prevent a UI update send message to background script to update last used date
await chrome.runtime.sendMessage({
command: "updateLastUsedDate",
cipherId: options.cipher.id,
});
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.

View File

@@ -317,6 +317,7 @@ describe("CollectAutofillContentService", () => {
__form__0: {
opid: "__form__0",
htmlAction: formAction,
htmlClass: null,
htmlName: formName,
htmlID: formId,
htmlMethod: formMethod,
@@ -544,6 +545,7 @@ describe("CollectAutofillContentService", () => {
__form__0: {
opid: "__form__0",
htmlAction: formAction1,
htmlClass: null,
htmlName: formName1,
htmlID: formId1,
htmlMethod: formMethod1,
@@ -551,6 +553,7 @@ describe("CollectAutofillContentService", () => {
__form__1: {
opid: "__form__1",
htmlAction: formAction2,
htmlClass: null,
htmlName: formName2,
htmlID: formId2,
htmlMethod: formMethod2,

View File

@@ -228,6 +228,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
opid: formElement.opid,
htmlAction: this.getFormActionAttribute(formElement),
htmlName: this.getPropertyOrAttribute(formElement, "name"),
htmlClass: this.getPropertyOrAttribute(formElement, "class"),
htmlID: this.getPropertyOrAttribute(formElement, "id"),
htmlMethod: this.getPropertyOrAttribute(formElement, "method"),
});
@@ -982,8 +983,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
const queueLength = this.mutationsQueue.length;
if (!this.domQueryService.pageContainsShadowDomElements()) {
// Checking if a page contains shadowDOM elements is a heavy operation and doesn't have to be done immediately, so we can call this within an idle moment on the event loop.
requestIdleCallbackPolyfill(this.checkPageContainsShadowDom, { timeout: 500 });
this.checkPageContainsShadowDom();
}
for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) {

View File

@@ -56,6 +56,7 @@ export class InlineMenuFieldQualificationService
"neuer benutzer",
"neues passwort",
"neue e-mail",
"pwdcheck",
];
private updatePasswordFieldKeywords = [
"update password",

View File

@@ -809,7 +809,7 @@ export default class MainBackground {
this.apiService,
);
this.ssoLoginService = new SsoLoginService(this.stateProvider);
this.ssoLoginService = new SsoLoginService(this.stateProvider, this.logService);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
@@ -1142,6 +1142,7 @@ export default class MainBackground {
this.accountService,
lockService,
this.billingAccountProfileStateService,
this.cipherService,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.keyService,

View File

@@ -16,6 +16,7 @@ import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/m
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { BiometricsCommands } from "@bitwarden/key-management";
@@ -53,6 +54,7 @@ export default class RuntimeBackground {
private accountService: AccountService,
private readonly lockService: LockService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private cipherService: CipherService,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@@ -200,6 +202,9 @@ export default class RuntimeBackground {
case BiometricsCommands.GetBiometricsStatusForUser: {
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
}
case "updateLastUsedDate": {
return await this.cipherService.updateLastUsedDate(msg.cipherId);
}
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
return await this.configService.getFeatureFlag(
FeatureFlag.UseTreeWalkerApiForPageDetailsCollection,

View File

@@ -7,4 +7,5 @@
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null"
showAutofillButton
[primaryActionAutofill]="clickItemsToAutofillVaultView"
[groupByType]="groupByType()"
></app-vault-list-items-container>

View File

@@ -1,5 +1,6 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -48,6 +49,10 @@ export class AutofillVaultListItemsComponent implements OnInit {
clickItemsToAutofillVaultView = false;
protected groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);
/**
* Observable that determines whether the empty autofill tip should be shown.
* The tip is shown when there are no login ciphers to autofill, no filter is applied, and autofill is allowed in

View File

@@ -27,7 +27,7 @@
<button type="button" bitMenuItem (click)="toggleFavorite()">
{{ favoriteText | i18n }}
</button>
<ng-container *ngIf="canEdit">
<ng-container *ngIf="canEdit && canViewPassword">
<a bitMenuItem (click)="clone()" *ngIf="canClone$ | async">
{{ "clone" | i18n }}
</a>

View File

@@ -97,6 +97,9 @@ export class ItemMoreOptionsComponent implements OnInit {
return this.cipher.edit;
}
get canViewPassword() {
return this.cipher.viewPassword;
}
/**
* Determines if the cipher can be autofilled.
*/

View File

@@ -11,11 +11,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
export interface NewItemInitialValues {
folderId?: string;
@@ -72,6 +72,6 @@ export class NewItemDropdownV2Component implements OnInit {
}
openFolderDialog() {
this.dialogService.open(AddEditFolderDialogComponent);
AddEditFolderDialogComponent.open(this.dialogService);
}
}

View File

@@ -6,11 +6,12 @@ import { combineLatest, map, take } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DisclosureTriggerForDirective, IconButtonModule } from "@bitwarden/components";
import {
DisclosureComponent,
DisclosureTriggerForDirective,
IconButtonModule,
} from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { DisclosureComponent } from "../../../../../../../../libs/components/src/disclosure/disclosure.component";
import { runInsideAngular } from "../../../../../platform/browser/run-inside-angular.operator";
import { VaultPopupListFiltersService } from "../../../../../vault/popup/services/vault-popup-list-filters.service";
import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component";

View File

@@ -1,9 +1,13 @@
<bit-section *ngIf="ciphers?.length > 0 || description" [disableMargin]="disableSectionMargin">
<bit-section
*ngIf="cipherGroups$().length > 0 || description"
[disableMargin]="disableSectionMargin"
>
<ng-container *ngIf="collapsibleKey">
<button
class="tw-group/vault-section-header hover:tw-bg-secondary-100 tw-pl-1 tw-w-full tw-border-x-0 tw-border-t-0 tw-border-b tw-border-solid focus-visible:tw-outline-none focus-visible:tw-rounded-md focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
class="tw-group/vault-section-header hover:tw-bg-primary-100 tw-rounded-md tw-pl-1 tw-w-full tw-border-x-0 tw-border-t-0 tw-border-b tw-border-solid focus-visible:tw-outline-none focus-visible:tw-ring-inset focus-visible:tw-ring-2 focus-visible:tw-ring-primary-600"
[ngClass]="{
'tw-border-b-secondary-300': !sectionOpenState(),
'tw-border-b-secondary-300 tw-rounded-b-none [&:is(:hover,:focus-visible)]:tw-border-b-transparent [&:is(:hover,:focus-visible)]:tw-rounded-b-md':
!sectionOpenState(),
'tw-border-b-transparent': sectionOpenState(),
}"
type="button"
@@ -17,6 +21,7 @@
<ng-container *ngTemplateOutlet="itemGroup"></ng-container>
</bit-disclosure>
</ng-container>
<ng-container *ngIf="!collapsibleKey">
<div class="tw-pl-1">
<ng-container *ngTemplateOutlet="sectionHeader"></ng-container>
@@ -27,7 +32,7 @@
</bit-section>
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mt-0.5 -tw-mx-0.5">
<bit-section-header class="tw-p-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title }}
</h2>
@@ -47,11 +52,11 @@
'tw-hidden': collapsibleKey && !sectionOpenState(),
}"
>
{{ ciphers.length }}
{{ ciphers().length }}
</span>
<span class="tw-pr-1" *ngIf="collapsibleKey">
<i
class="bwi"
class="bwi tw-text-main"
[ngClass]="{
'bwi-angle-down tw-inline-block': !sectionOpenState(),
'bwi-angle-up tw-hidden group-hover/vault-section-header:tw-inline-block group-focus-visible/vault-section-header:tw-inline-block':
@@ -72,69 +77,78 @@
<ng-template #itemGroup>
<bit-item-group>
<cdk-virtual-scroll-viewport
[itemSize]="itemHeight$ | async"
class="tw-overflow-visible [&>.cdk-virtual-scroll-content-wrapper]:[contain:layout_style]"
>
<bit-item *cdkVirtualFor="let cipher of ciphers">
<button
bit-item-content
type="button"
(click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="cipherItemTitleKey | async | i18n: cipher.name"
class="{{ itemHeightClass }}"
>
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
<i
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="cipher.hasAttachments"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
</button>
<ng-container slot="end">
<bit-item-action *ngIf="!(hideAutofillButton$ | async)">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</button>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton && cipher.canLaunch">
<button
type="button"
bitIconButton="bwi-external-link"
size="small"
(click)="launchCipher(cipher)"
[attr.aria-label]="'launchWebsiteName' | i18n: cipher.name"
[title]="'launchWebsiteName' | i18n: cipher.name"
></button>
</bit-item-action>
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideAutofillOptions]="hideAutofillOptions$ | async"
[showViewOption]="primaryActionAutofill"
></app-item-more-options>
</ng-container>
</bit-item>
</cdk-virtual-scroll-viewport>
<ng-container *ngFor="let group of cipherGroups$()">
<ng-container *ngIf="group.subHeaderKey">
<h3 class="tw-text-muted tw-text-xs tw-font-semibold tw-pl-1 tw-mb-1 bit-compact:tw-m-0">
{{ group.subHeaderKey | i18n }}
</h3>
</ng-container>
<cdk-virtual-scroll-viewport
[itemSize]="itemHeight$ | async"
class="tw-overflow-visible [&>.cdk-virtual-scroll-content-wrapper]:[contain:layout_style]"
>
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<button
bit-item-content
type="button"
(click)="primaryActionOnSelect(cipher)"
(dblclick)="launchCipher(cipher)"
[appA11yTitle]="cipherItemTitleKey | async | i18n: cipher.name"
class="{{ itemHeightClass }}"
>
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
<i
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="cipher.hasAttachments"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<span slot="secondary">{{ cipher.subTitle }}</span>
</button>
<ng-container slot="end">
<bit-item-action *ngIf="!(hideAutofillButton$ | async)">
<button
type="button"
bitBadge
variant="primary"
(click)="doAutofill(cipher)"
[title]="autofillShortcutTooltip() ?? ('autofillTitle' | i18n: cipher.name)"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "fill" | i18n }}
</button>
</bit-item-action>
<bit-item-action *ngIf="!showAutofillButton && cipher.canLaunch">
<button
type="button"
bitIconButton="bwi-external-link"
size="small"
(click)="launchCipher(cipher)"
[attr.aria-label]="'launchWebsiteName' | i18n: cipher.name"
[title]="'launchWebsiteName' | i18n: cipher.name"
></button>
</bit-item-action>
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideAutofillOptions]="hideAutofillOptions$ | async"
[showViewOption]="primaryActionAutofill"
></app-item-more-options>
</ng-container>
</bit-item>
</cdk-virtual-scroll-viewport>
</ng-container>
</bit-item-group>
</ng-template>

View File

@@ -9,11 +9,14 @@ import {
EventEmitter,
inject,
Input,
OnInit,
Output,
Signal,
signal,
ViewChild,
computed,
OnInit,
ChangeDetectionStrategy,
input,
} from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, Observable, map } from "rxjs";
@@ -23,6 +26,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
BadgeModule,
@@ -73,6 +77,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
private compactModeService = inject(CompactModeService);
@@ -110,11 +115,51 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
*/
private viewCipherTimeout: number | null;
ciphers = input<PopupCipherView[]>([]);
/**
* The list of ciphers to display.
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
@Input()
ciphers: PopupCipherView[] = [];
groupByType = input<boolean>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
cipherGroups$ = computed<
{
subHeaderKey?: string | null;
ciphers: PopupCipherView[];
}[]
>(() => {
const groups: { [key: string]: CipherView[] } = {};
this.ciphers().forEach((cipher) => {
let groupKey;
if (this.groupByType()) {
switch (cipher.type) {
case CipherType.Card:
groupKey = "cards";
break;
case CipherType.Identity:
groupKey = "identities";
break;
}
}
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(cipher);
});
return Object.keys(groups).map((key) => ({
subHeaderKey: this.groupByType ? key : "",
ciphers: groups[key],
}));
});
/**
* Title for the vault list item section.

View File

@@ -11,10 +11,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordHistoryViewComponent } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PasswordHistoryViewComponent } from "../../../../../../../../libs/vault/src/components/password-history-view/password-history-view.component";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";

View File

@@ -69,13 +69,13 @@
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="favoriteCiphers$ | async"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="remainingCiphers$ | async"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"

View File

@@ -23,6 +23,7 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -35,14 +36,8 @@ import {
SearchModule,
ToastService,
} from "@bitwarden/components";
import { CopyCipherFieldService } from "@bitwarden/vault";
import { CipherViewComponent, CopyCipherFieldService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";

View File

@@ -21,14 +21,12 @@ import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { InlineMenuFieldQualificationService } from "../../../../../browser/src/autofill/services/inline-menu-field-qualification.service";
import {
AutoFillOptions,
AutofillService,
PageDetail,
} from "../../../autofill/services/abstractions/autofill.service";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";

View File

@@ -27,13 +27,11 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { InlineMenuFieldQualificationService } from "../../../../../browser/src/autofill/services/inline-menu-field-qualification.service";
import {
AutofillService,
PageDetail,
} from "../../../autofill/services/abstractions/autofill.service";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { closeViewVaultItemPopout, VaultPopoutType } from "../utils/vault-popout-window";

View File

@@ -17,9 +17,7 @@ import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { InlineMenuFieldQualificationService } from "../../../../../browser/src/autofill/services/inline-menu-field-qualification.service";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { VaultPopupAutofillService } from "./vault-popup-autofill.service";

View File

@@ -214,6 +214,7 @@ export class VaultPopupItemsService {
map(([hasSearchText, filters]) => {
return hasSearchText || Object.values(filters).some((filter) => filter !== null);
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**

View File

@@ -14,17 +14,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { BadgeModule, CheckboxModule, Option } from "@bitwarden/components";
import {
BadgeModule,
CardComponent,
CheckboxModule,
FormFieldModule,
Option,
SelectModule,
} from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { CardComponent } from "../../../../../../libs/components/src/card/card.component";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FormFieldModule } from "../../../../../../libs/components/src/form-field/form-field.module";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { SelectModule } from "../../../../../../libs/components/src/select/select.module";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupCompactModeService } from "../../../platform/popup/layout/popup-compact-mode.service";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";

View File

@@ -14,10 +14,10 @@ import { UserId } from "@bitwarden/common/types/guid";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogService } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { AddEditFolderDialogComponent } from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component";
import { FoldersV2Component } from "./folders-v2.component";
@@ -27,8 +27,8 @@ import { FoldersV2Component } from "./folders-v2.component";
template: `<ng-content></ng-content>`,
})
class MockPopupHeaderComponent {
@Input() pageTitle: string;
@Input() backAction: () => void;
@Input() pageTitle: string = "";
@Input() backAction: () => void = () => {};
}
@Component({
@@ -37,14 +37,15 @@ class MockPopupHeaderComponent {
template: `<ng-content></ng-content>`,
})
class MockPopupFooterComponent {
@Input() pageTitle: string;
@Input() pageTitle: string = "";
}
describe("FoldersV2Component", () => {
let component: FoldersV2Component;
let fixture: ComponentFixture<FoldersV2Component>;
const folderViews$ = new BehaviorSubject<FolderView[]>([]);
const open = jest.fn();
const open = jest.spyOn(AddEditFolderDialogComponent, "open");
const mockDialogService = { open: jest.fn() };
beforeEach(async () => {
open.mockClear();
@@ -68,7 +69,7 @@ describe("FoldersV2Component", () => {
imports: [MockPopupHeaderComponent, MockPopupFooterComponent],
},
})
.overrideProvider(DialogService, { useValue: { open } })
.overrideProvider(DialogService, { useValue: mockDialogService })
.compileComponents();
fixture = TestBed.createComponent(FoldersV2Component);
@@ -101,9 +102,7 @@ describe("FoldersV2Component", () => {
editButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, {
data: { editFolderConfig: { folder } },
});
expect(open).toHaveBeenCalledWith(mockDialogService, { editFolderConfig: { folder } });
});
it("opens add dialog for new folder when there are no folders", () => {
@@ -114,6 +113,6 @@ describe("FoldersV2Component", () => {
addButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { data: {} });
expect(open).toHaveBeenCalledWith(mockDialogService, {});
});
});

View File

@@ -12,25 +12,14 @@ import {
ButtonModule,
DialogService,
IconButtonModule,
ItemModule,
NoItemsModule,
} from "@bitwarden/components";
import { VaultIcons } from "@bitwarden/vault";
import { AddEditFolderDialogComponent, VaultIcons } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ItemGroupComponent } from "../../../../../../libs/components/src/item/item-group.component";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ItemModule } from "../../../../../../libs/components/src/item/item.module";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NoItemsModule } from "../../../../../../libs/components/src/no-items/no-items.module";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogData,
} from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component";
@Component({
standalone: true,
@@ -42,7 +31,6 @@ import {
PopupPageComponent,
PopupHeaderComponent,
ItemModule,
ItemGroupComponent,
NoItemsModule,
IconButtonModule,
ButtonModule,
@@ -78,8 +66,6 @@ export class FoldersV2Component {
// If a folder is provided, the edit variant should be shown
const editFolderConfig = folder ? { folder } : undefined;
this.dialogService.open<unknown, AddEditFolderDialogData>(AddEditFolderDialogComponent, {
data: { editFolderConfig },
});
AddEditFolderDialogComponent.open(this.dialogService, { editFolderConfig });
}
}

View File

@@ -123,6 +123,9 @@ export class EditCommand {
"Item does not belong to an organization. Consider moving it first.",
);
}
if (!cipher.viewPassword) {
return Response.noEditPermission();
}
cipher.collectionIds = req;
try {

View File

@@ -39,6 +39,10 @@ export class Response {
return Response.error("Not found.");
}
static noEditPermission(): Response {
return Response.error("You do not have permission to edit this item");
}
static badRequest(message: string): Response {
return Response.error(message);
}

View File

@@ -5,3 +5,4 @@ index.node
npm-debug.log*
*.node
dist
windows_pluginauthenticator_bindings.rs

View File

@@ -410,6 +410,26 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags",
"cexpr",
"clang-sys",
"itertools",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn",
]
[[package]]
name = "bitflags"
version = "2.8.0"
@@ -553,6 +573,15 @@ dependencies = [
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -593,6 +622,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "clap"
version = "4.5.27"
@@ -1458,6 +1498,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@@ -2259,6 +2308,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483f8c21f64f3ea09fe0f30f5d48c3e8eefe5dac9129f0075f76593b4c1da705"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro-crate"
version = "3.2.0"
@@ -2437,6 +2496,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3447,6 +3512,13 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-plugin-authenticator"
version = "0.0.0"
dependencies = [
"bindgen",
]
[[package]]
name = "windows-registry"
version = "0.4.0"

View File

@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["napi", "core", "proxy", "macos_provider"]
members = ["napi", "core", "proxy", "macos_provider", "windows-plugin-authenticator"]
[workspace.dependencies]
anyhow = "=1.0.94"

View File

@@ -0,0 +1,9 @@
[package]
name = "windows-plugin-authenticator"
version = "0.0.0"
edition = "2021"
license = "GPL-3.0"
publish = false
[target.'cfg(target_os = "windows")'.build-dependencies]
bindgen = "0.71.1"

View File

@@ -0,0 +1,23 @@
# windows-plugin-authenticator
This is an internal crate that's meant to be a safe abstraction layer over the generated Rust bindings for the Windows WebAuthn Plugin Authenticator API's.
You can find more information about the Windows WebAuthn API's [here](https://github.com/microsoft/webauthn).
## Building
To build this crate, set the following environment variables:
- `LIBCLANG_PATH` -> the path to the `bin` directory of your LLVM install ([more info](https://rust-lang.github.io/rust-bindgen/requirements.html?highlight=libclang_path#installing-clang))
### Bash Example
```
export LIBCLANG_PATH='C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```
### PowerShell Example
```
$env:LIBCLANG_PATH = 'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\x64\bin'
```

View File

@@ -0,0 +1,22 @@
fn main() {
#[cfg(target_os = "windows")]
windows();
}
#[cfg(target_os = "windows")]
fn windows() {
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
let bindings = bindgen::Builder::default()
.header("pluginauthenticator.hpp")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings.");
bindings
.write_to_file(format!(
"{}\\windows_pluginauthenticator_bindings.rs",
out_dir
))
.expect("Couldn't write bindings.");
}

View File

@@ -0,0 +1,231 @@
/*
Bitwarden's pluginauthenticator.hpp
Source: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
This is a C++ header file, so the extension has been manually
changed from `.h` to `.hpp`, so bindgen will automatically
generate the correct C++ bindings.
More Info: https://rust-lang.github.io/rust-bindgen/cpp.html
*/
/* this ALWAYS GENERATED file contains the definitions for the interfaces */
/* File created by MIDL compiler version 8.01.0628 */
/* @@MIDL_FILE_HEADING( ) */
/* verify that the <rpcndr.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCNDR_H_VERSION__
#define __REQUIRED_RPCNDR_H_VERSION__ 501
#endif
/* verify that the <rpcsal.h> version is high enough to compile this file*/
#ifndef __REQUIRED_RPCSAL_H_VERSION__
#define __REQUIRED_RPCSAL_H_VERSION__ 100
#endif
#include "rpc.h"
#include "rpcndr.h"
#ifndef __RPCNDR_H_VERSION__
#error this stub requires an updated version of <rpcndr.h>
#endif /* __RPCNDR_H_VERSION__ */
#ifndef COM_NO_WINDOWS_H
#include "windows.h"
#include "ole2.h"
#endif /*COM_NO_WINDOWS_H*/
#ifndef __pluginauthenticator_h__
#define __pluginauthenticator_h__
#if defined(_MSC_VER) && (_MSC_VER >= 1020)
#pragma once
#endif
#ifndef DECLSPEC_XFGVIRT
#if defined(_CONTROL_FLOW_GUARD_XFG)
#define DECLSPEC_XFGVIRT(base, func) __declspec(xfg_virtual(base, func))
#else
#define DECLSPEC_XFGVIRT(base, func)
#endif
#endif
/* Forward Declarations */
#ifndef __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__
typedef interface EXPERIMENTAL_IPluginAuthenticator EXPERIMENTAL_IPluginAuthenticator;
#endif /* __EXPERIMENTAL_IPluginAuthenticator_FWD_DEFINED__ */
/* header files for imported files */
#include "oaidl.h"
#include "webauthn.h"
#ifdef __cplusplus
extern "C"{
#endif
/* interface __MIDL_itf_pluginauthenticator_0000_0000 */
/* [local] */
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
{
HWND hWnd;
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
DWORD cbEncodedRequest;
/* [size_is] */ byte *pbEncodedRequest;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
{
DWORD cbEncodedResponse;
/* [size_is] */ byte *pbEncodedResponse;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_RESPONSE;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
{
GUID transactionId;
DWORD cbRequestSignature;
/* [size_is] */ byte *pbRequestSignature;
} EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef struct _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
typedef const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST *EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_c_ifspec;
extern RPC_IF_HANDLE __MIDL_itf_pluginauthenticator_0000_0000_v0_0_s_ifspec;
#ifndef __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
#define __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__
/* interface EXPERIMENTAL_IPluginAuthenticator */
/* [unique][version][uuid][object] */
EXTERN_C const IID IID_EXPERIMENTAL_IPluginAuthenticator;
#if defined(__cplusplus) && !defined(CINTERFACE)
MIDL_INTERFACE("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")
EXPERIMENTAL_IPluginAuthenticator : public IUnknown
{
public:
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginMakeCredential(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginGetAssertion(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response) = 0;
virtual HRESULT STDMETHODCALLTYPE EXPERIMENTAL_PluginCancelOperation(
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request) = 0;
};
#else /* C style interface */
typedef struct EXPERIMENTAL_IPluginAuthenticatorVtbl
{
BEGIN_INTERFACE
DECLSPEC_XFGVIRT(IUnknown, QueryInterface)
HRESULT ( STDMETHODCALLTYPE *QueryInterface )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in REFIID riid,
/* [annotation][iid_is][out] */
_COM_Outptr_ void **ppvObject);
DECLSPEC_XFGVIRT(IUnknown, AddRef)
ULONG ( STDMETHODCALLTYPE *AddRef )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(IUnknown, Release)
ULONG ( STDMETHODCALLTYPE *Release )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginMakeCredential)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginMakeCredential )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginGetAssertion)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginGetAssertion )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST request,
/* [out] */ __RPC__deref_out_opt EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE *response);
DECLSPEC_XFGVIRT(EXPERIMENTAL_IPluginAuthenticator, EXPERIMENTAL_PluginCancelOperation)
HRESULT ( STDMETHODCALLTYPE *EXPERIMENTAL_PluginCancelOperation )(
__RPC__in EXPERIMENTAL_IPluginAuthenticator * This,
/* [in] */ __RPC__in EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST request);
END_INTERFACE
} EXPERIMENTAL_IPluginAuthenticatorVtbl;
interface EXPERIMENTAL_IPluginAuthenticator
{
CONST_VTBL struct EXPERIMENTAL_IPluginAuthenticatorVtbl *lpVtbl;
};
#ifdef COBJMACROS
#define EXPERIMENTAL_IPluginAuthenticator_QueryInterface(This,riid,ppvObject) \
( (This)->lpVtbl -> QueryInterface(This,riid,ppvObject) )
#define EXPERIMENTAL_IPluginAuthenticator_AddRef(This) \
( (This)->lpVtbl -> AddRef(This) )
#define EXPERIMENTAL_IPluginAuthenticator_Release(This) \
( (This)->lpVtbl -> Release(This) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginMakeCredential(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginMakeCredential(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginGetAssertion(This,request,response) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginGetAssertion(This,request,response) )
#define EXPERIMENTAL_IPluginAuthenticator_EXPERIMENTAL_PluginCancelOperation(This,request) \
( (This)->lpVtbl -> EXPERIMENTAL_PluginCancelOperation(This,request) )
#endif /* COBJMACROS */
#endif /* C style interface */
#endif /* __EXPERIMENTAL_IPluginAuthenticator_INTERFACE_DEFINED__ */
/* Additional Prototypes for ALL interfaces */
unsigned long __RPC_USER HWND_UserSize( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree( __RPC__in unsigned long *, __RPC__in HWND * );
unsigned long __RPC_USER HWND_UserSize64( __RPC__in unsigned long *, unsigned long , __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserMarshal64( __RPC__in unsigned long *, __RPC__inout_xcount(0) unsigned char *, __RPC__in HWND * );
unsigned char * __RPC_USER HWND_UserUnmarshal64(__RPC__in unsigned long *, __RPC__in_xcount(0) unsigned char *, __RPC__out HWND * );
void __RPC_USER HWND_UserFree64( __RPC__in unsigned long *, __RPC__in HWND * );
/* end of Additional Prototypes */
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,11 @@
#![cfg(target_os = "windows")]
mod pa;
pub fn get_version_number() -> u64 {
unsafe { pa::WebAuthNGetApiVersionNumber() }.into()
}
pub fn add_authenticator() {
unimplemented!();
}

View File

@@ -0,0 +1,15 @@
/*
The 'pa' (plugin authenticator) module will contain the generated
bindgen code.
The attributes below will suppress warnings from the generated code.
*/
#![cfg(target_os = "windows")]
#![allow(clippy::all)]
#![allow(warnings)]
include!(concat!(
env!("OUT_DIR"),
"/windows_pluginauthenticator_bindings.rs"
));

View File

@@ -16,6 +16,8 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<!--
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>

View File

@@ -21,6 +21,7 @@
type="checkbox"
[(ngModel)]="$any(c).checked"
name="Collection[{{ i }}].Checked"
[disabled]="!cipher.canAssignToCollections"
/>
</div>
</div>

View File

@@ -1,158 +0,0 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
class="tw-container tw-mx-auto"
[formGroup]="formGroup"
>
<div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input
id="register-form_input_email"
bitInput
type="email"
formControlName="email"
[attr.readonly]="queryParamFromOrgInvite ? true : null"
/>
<bit-hint>{{ "emailAddressDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input id="register-form_input_name" bitInput type="text" formControlName="name" />
<bit-hint>{{ "yourNameDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
id="register-form_input_master-password"
bitInput
type="password"
formControlName="masterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<span class="tw-font-semibold">{{ "important" | i18n }}</span>
{{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }}
</bit-hint>
</bit-form-field>
<app-password-strength
[password]="formGroup.get('masterPassword')?.value"
[email]="formGroup.get('email')?.value"
[name]="formGroup.get('name')?.value"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "reTypeMasterPass" | i18n }}</bit-label>
<input
id="register-form_input_confirm-master-password"
bitInput
type="password"
formControlName="confirmMasterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input id="register-form_input_hint" bitInput type="text" formControlName="hint" />
<bit-hint>{{ "masterPassHintDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="tw-mb-4 tw-flex tw-items-start">
<input
class="mt-1"
type="checkbox"
bitCheckbox
id="checkForBreaches"
name="CheckBreach"
formControlName="checkForBreaches"
/>
<bit-label for="checkForBreaches"> {{ "checkForBreaches" | i18n }}</bit-label>
</div>
<div class="tw-mb-3 tw-flex tw-items-start" *ngIf="showTerms">
<input
class="mt-1"
id="register-form-input-accept-policies"
bitCheckbox
type="checkbox"
formControlName="acceptPolicies"
/>
<bit-label for="register-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}<br />
<a bitLink href="https://bitwarden.com/terms/" target="_blank" rel="noreferrer">{{
"termsOfService" | i18n
}}</a
>,
<a bitLink href="https://bitwarden.com/privacy/" target="_blank" rel="noreferrer">{{
"privacyPolicy" | i18n
}}</a>
</bit-label>
</div>
<div class="tw-space-x-2 tw-pt-2">
<ng-container *ngIf="!accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "createAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "logIn" | i18n }}
</button>
</ng-container>
</div>
<p class="tw-m-0 tw-mt-5 tw-text-sm">
{{ "alreadyHaveAccount" | i18n }}
<a bitLink routerLink="/login">{{ "logIn" | i18n }}</a>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</div>
</form>

View File

@@ -1,115 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { RegisterRequest } from "@bitwarden/common/models/request/register.request";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
@Component({
selector: "app-register-form",
templateUrl: "./register-form.component.html",
})
export class RegisterFormComponent extends BaseRegisterComponent implements OnInit {
@Input() queryParamEmail: string;
@Input() queryParamFromOrgInvite: boolean;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
@Input() referenceDataValue: ReferenceEventRequest;
showErrorSummary = false;
characterMinimumMessage: string;
constructor(
formValidationErrorService: FormValidationErrorsService,
formBuilder: UntypedFormBuilder,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
keyService: KeyService,
apiService: ApiService,
platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
environmentService: EnvironmentService,
logService: LogService,
auditService: AuditService,
dialogService: DialogService,
acceptOrgInviteService: AcceptOrganizationInviteService,
toastService: ToastService,
) {
super(
formValidationErrorService,
formBuilder,
loginStrategyService,
router,
i18nService,
keyService,
apiService,
platformUtilsService,
environmentService,
logService,
auditService,
dialogService,
toastService,
);
this.modifyRegisterRequest = async (request: RegisterRequest) => {
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
// Org user id and token are included here only for validation and two factor purposes.
const orgInvite = await acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
request.organizationUserId = orgInvite.organizationUserId;
request.token = orgInvite.token;
}
// Invite is accepted after login (on deep link redirect).
};
}
async ngOnInit() {
await super.ngOnInit();
this.referenceData = this.referenceDataValue;
if (this.queryParamEmail) {
this.formGroup.get("email")?.setValue(this.queryParamEmail);
}
if (this.enforcedPolicyOptions != null && this.enforcedPolicyOptions.minLength > 0) {
this.characterMinimumMessage = "";
} else {
this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength);
}
}
async submit() {
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score,
this.formGroup.value.masterPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return;
}
await super.submit(false);
}
}

View File

@@ -1,14 +0,0 @@
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { SharedModule } from "../../shared";
import { RegisterFormComponent } from "./register-form.component";
@NgModule({
imports: [SharedModule, PasswordCalloutComponent],
declarations: [RegisterFormComponent],
exports: [RegisterFormComponent],
})
export class RegisterFormModule {}

View File

@@ -118,7 +118,13 @@
) | currency: "$"
}}
</b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
<span class="tw-text-xs tw-px-0">
/{{
selectableProduct.productTier === productTypes.Families
? "month"
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<ng-container
*ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption"

View File

@@ -1045,10 +1045,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.estimatedTax = invoice.taxAmount;
})
.catch((error) => {
const translatedMessage = this.i18nService.t(error.message);
this.toastService.showToast({
title: "",
variant: "error",
message: this.i18nService.t(error.message),
message:
!translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
});
});
}

View File

@@ -433,7 +433,11 @@
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }}
</p>
<app-payment *ngIf="createOrganization || upgradeRequiresPaymentMethod"></app-payment>
<app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
[showAccountCredit]="false"
>
</app-payment>
<app-manage-tax-information
class="tw-my-4"
[showTaxIdField]="showTaxIdField"

View File

@@ -294,6 +294,10 @@
</ng-template>
<ng-template #setupSelfHost>
<ng-container *ngIf="userOrg.hasReseller && resellerSeatsRemainingMessage">
<h2 bitTypography="h2" class="tw-mt-7">{{ "manageSubscription" | i18n }}</h2>
<p bitTypography="body1">{{ resellerSeatsRemainingMessage }}</p>
</ng-container>
<ng-container *ngIf="showSelfHost">
<h2 bitTypography="h2" class="tw-mt-7">
{{ "selfHostingTitleProper" | i18n }}

View File

@@ -4,13 +4,17 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom, Observable, Subject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums";
import {
OrganizationApiKeyType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -61,12 +65,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
showSubscription = true;
showSelfHost = false;
organizationIsManagedByConsolidatedBillingMSP = false;
resellerSeatsRemainingMessage: string;
protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon;
protected readonly teamsStarter = ProductTierType.TeamsStarter;
private destroy$ = new Subject<void>();
private seatsRemainingMessage: string;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
@@ -79,6 +86,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
async ngOnInit() {
@@ -104,6 +112,28 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}
}
}
if (this.userOrg.hasReseller) {
const allUsers = await this.organizationUserApiService.getAllUsers(this.userOrg.id);
const userCount = allUsers.data.filter((user) =>
[
OrganizationUserStatusType.Invited,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
].includes(user.status),
).length;
const remainingSeats = this.userOrg.seats - userCount;
const seatsRemaining = this.i18nService.t(
"seatsRemaining",
remainingSeats.toString(),
this.userOrg.seats.toString(),
);
this.resellerSeatsRemainingMessage = seatsRemaining;
}
}
ngOnDestroy() {

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="subLabels.createAccount"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="createAccountLabel"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -19,7 +19,16 @@ import {
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import { ValidOrgParams } from "../trial-initiation.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
const trialFlowOrgs = [
ValidOrgParams.teams,

View File

@@ -1,149 +0,0 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<app-secrets-manager-trial
*ngIf="layout === layouts.secretsManager; else passwordManagerTrial"
></app-secrets-manager-trial>
<ng-template #passwordManagerTrial>
<div *ngIf="accountCreateOnly" class="">
<h1 class="tw-mt-12 tw-text-center tw-text-xl">{{ "createAccount" | i18n }}</h1>
<div
class="tw-min-w-xl tw-m-auto tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
>
<app-register-form
[queryParamEmail]="email"
[queryParamFromOrgInvite]="fromOrgInvite"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div *ngIf="!accountCreateOnly">
<div class="tw-absolute tw--z-10 tw--mt-48 tw-h-[28rem] tw-w-full tw-bg-background-alt2"></div>
<div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl tw-gap-12 tw-px-4">
<div class="tw-w-1/2">
<img
alt="Bitwarden"
style="height: 50px; width: 335px"
class="tw-mt-6"
src="../../images/register-layout/logo-horizontal-white.svg"
/>
<div class="tw-pt-12">
<!-- Layout params are used by marketing to determine left-hand content -->
<app-default-content *ngIf="layout === layouts.default"></app-default-content>
<app-teams-content *ngIf="layout === layouts.teams"></app-teams-content>
<app-teams1-content *ngIf="layout === layouts.teams1"></app-teams1-content>
<app-teams2-content *ngIf="layout === layouts.teams2"></app-teams2-content>
<app-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
<app-cnet-enterprise-content
*ngIf="layout === layouts.cnetcmpgnent"
></app-cnet-enterprise-content>
<app-cnet-individual-content
*ngIf="layout === layouts.cnetcmpgnind"
></app-cnet-individual-content>
<app-cnet-teams-content
*ngIf="layout === layouts.cnetcmpgnteams"
></app-cnet-teams-content>
<app-abm-enterprise-content
*ngIf="layout === layouts.abmenterprise"
></app-abm-enterprise-content>
<app-abm-teams-content *ngIf="layout === layouts.abmteams"></app-abm-teams-content>
</div>
</div>
<div class="tw-w-1/2">
<div *ngIf="!useTrialStepper">
<div
class="tw-min-w-xl tw-m-auto tw-mt-28 tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
>
<app-register-form
[queryParamEmail]="email"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div class="tw-pt-44" *ngIf="useTrialStepper">
<div
class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background"
>
<div class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100">
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ freeTrialText }}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
></environment-selector>
</div>
<app-vertical-stepper #stepper linear (selectionChange)="stepSelectionChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<app-register-form
[isInTrialFlow]="true"
(createdAccount)="createdAccount($event)"
[referenceDataValue]="referenceData"
></app-register-form>
</app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid"
[loading]="loading"
(click)="createOrganizationOnTrial()"
>
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.get('name').value,
email: orgInfoFormGroup.get('email').value,
type: trialOrganizationType,
}"
[subscriptionProduct]="SubscriptionProduct.PasswordManager"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button
type="button"
bitButton
buttonType="primary"
(click)="navigateToOrgVault()"
>
{{ "getStarted" | i18n | titlecase }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateToOrgInvite()"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
</div>
</div>
</div>
</div>
</ng-template>

View File

@@ -1,336 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { FormBuilder, UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service";
import { OrganizationInvite } from "../../auth/organization-invite/organization-invite";
import { RouterService } from "../../core";
import { SharedModule } from "../../shared";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
describe("TrialInitiationComponent", () => {
let component: TrialInitiationComponent;
let fixture: ComponentFixture<TrialInitiationComponent>;
const mockQueryParams = new BehaviorSubject<any>({ org: "enterprise" });
const testOrgId = "91329456-5b9f-44b3-9279-6bb9ee6a0974";
const formBuilder: FormBuilder = new FormBuilder();
let routerSpy: jest.SpyInstance;
let stateServiceMock: MockProxy<StateService>;
let policyApiServiceMock: MockProxy<PolicyApiServiceAbstraction>;
let policyServiceMock: MockProxy<PolicyService>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
stateServiceMock = mock<StateService>();
policyApiServiceMock = mock<PolicyApiServiceAbstraction>();
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
organizationBillingServiceMock = mock<OrganizationBillingService>();
configServiceMock = mock<ConfigService>();
// 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
TestBed.configureTestingModule({
imports: [
SharedModule,
RouterTestingModule.withRoutes([
{ path: "trial", component: TrialInitiationComponent },
{
path: `organizations/${testOrgId}/vault`,
component: BlankComponent,
},
{
path: `organizations/${testOrgId}/members`,
component: BlankComponent,
},
]),
],
declarations: [TrialInitiationComponent, I18nPipe],
providers: [
UntypedFormBuilder,
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
},
},
{ provide: StateService, useValue: stateServiceMock },
{ provide: PolicyService, useValue: policyServiceMock },
{ provide: PolicyApiServiceAbstraction, useValue: policyApiServiceMock },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: TitleCasePipe, useValue: mock<TitleCasePipe>() },
{
provide: VerticalStepperComponent,
useClass: VerticalStepperStubComponent,
},
{
provide: RouterService,
useValue: routerServiceMock,
},
{
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
// These tests demonstrate mocking service calls
describe("onInit() enforcedPolicyOptions", () => {
it("should not set enforcedPolicyOptions if there isn't an org invite in deep linked url", async () => {
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(null);
// Need to recreate component with new service mock
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toBe(undefined);
});
it("should set enforcedPolicyOptions if the deep linked url has an org invite", async () => {
// Set up service method mocks
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
} as OrganizationInvite);
policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce(
Promise.resolve([
{
id: "345",
organizationId: testOrgId,
type: 1,
data: {
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
enabled: true,
},
] as Policy[]),
);
policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue(
of({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
} as MasterPasswordPolicyOptions),
);
// Need to recreate component with new service mocks
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toMatchObject({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
});
});
});
// These tests demonstrate route params
describe("Route params", () => {
it("should set org variable to be enterprise and plan to EnterpriseAnnually if org param is enterprise", fakeAsync(() => {
mockQueryParams.next({ org: "enterprise" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("enterprise");
expect(component.plan).toBe(PlanType.EnterpriseAnnually);
}));
it("should not set org variable if no org param is provided", fakeAsync(() => {
mockQueryParams.next({});
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should not set the org if org param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ org: "hahahaha" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should set the layout variable if layout param is valid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "teams1" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.layout).toBe("teams1");
expect(component.accountCreateOnly).toBe(false);
}));
it("should not set the layout variable and leave as 'default' if layout param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "asdfasdf" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
// 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
component.ngOnInit();
expect(component.layout).toBe("default");
expect(component.accountCreateOnly).toBe(true);
}));
});
// These tests demonstrate the use of a stub component
describe("createAccount()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set email and call verticalStepper.next()", fakeAsync(() => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.createdAccount("test@email.com");
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.email).toBe("test@email.com");
}));
});
describe("billingSuccess()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set orgId and call verticalStepper.next()", () => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.billingSuccess({ orgId: testOrgId });
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.orgId).toBe(testOrgId);
});
});
describe("stepSelectionChange()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("on step 2 should show organization copy text", () => {
component.stepSelectionChange({
selectedIndex: 1,
previouslySelectedIndex: 0,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Enter your");
expect(component.orgInfoSubLabel).toContain(" organization information");
});
it("going from step 2 to 3 should set the orgInforSubLabel to be the Org name from orgInfoFormGroup", () => {
component.orgInfoFormGroup = formBuilder.group({
name: ["Hooli"],
email: [""],
});
component.stepSelectionChange({
selectedIndex: 2,
previouslySelectedIndex: 1,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Hooli");
});
});
describe("previousStep()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should call verticalStepper.previous()", fakeAsync(() => {
const verticalStepperPrevious = jest.spyOn(component.verticalStepper, "previous");
component.previousStep();
expect(verticalStepperPrevious).toHaveBeenCalled();
}));
});
// These tests demonstrate router navigation
describe("navigation methods", () => {
beforeEach(() => {
component.orgId = testOrgId;
const router = TestBed.inject(Router);
fixture.detectChanges();
routerSpy = jest.spyOn(router, "navigate");
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgVault();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "vault"]);
}));
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgInvite();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "members"]);
}));
});
});
});
export class VerticalStepperStubComponent extends VerticalStepperComponent {}
export class BlankComponent {} // For router tests

View File

@@ -1,353 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
OrganizationInformation,
PlanInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AcceptOrganizationInviteService } from "../../auth/organization-invite/accept-organization.service";
import { OrganizationInvite } from "../../auth/organization-invite/organization-invite";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
enum ValidLayoutParams {
default = "default",
teams = "teams",
teams1 = "teams1",
teams2 = "teams2",
teams3 = "teams3",
enterprise = "enterprise",
enterprise1 = "enterprise1",
enterprise2 = "enterprise2",
cnetcmpgnent = "cnetcmpgnent",
cnetcmpgnind = "cnetcmpgnind",
cnetcmpgnteams = "cnetcmpgnteams",
abmenterprise = "abmenterprise",
abmteams = "abmteams",
secretsManager = "secretsManager",
}
@Component({
selector: "app-trial",
templateUrl: "trial-initiation.component.html",
})
export class TrialInitiationComponent implements OnInit, OnDestroy {
email = "";
fromOrgInvite = false;
org = "";
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
layout = "default";
plan: PlanType;
productTier: ProductTierType;
accountCreateOnly = true;
useTrialStepper = false;
loading = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];
routeFlowOrgs: string[] = [
ValidOrgParams.free,
ValidOrgParams.premium,
ValidOrgParams.individual,
];
layouts = ValidLayoutParams;
referenceData: ReferenceEventRequest;
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
email: [""],
});
private set referenceDataId(referenceId: string) {
if (referenceId != null) {
this.referenceData.id = referenceId;
} else {
this.referenceData.id = ("; " + document.cookie)
.split("; reference=")
.pop()
.split(";")
.shift();
}
if (this.referenceData.id === "") {
this.referenceData.id = null;
} else {
// Matches "_ga_QBRN562QQQ=value1.value2.session" and captures values and session.
const regex = /_ga_QBRN562QQQ=([^.]+)\.([^.]+)\.(\d+)/;
const match = document.cookie.match(regex);
if (match) {
this.referenceData.session = match[3];
}
}
}
private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
private route: ActivatedRoute,
protected router: Router,
private formBuilder: UntypedFormBuilder,
private titleCasePipe: TitleCasePipe,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
this.referenceData = new ReferenceEventRequest();
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
this.fromOrgInvite = qParams.fromOrgInvite === "true";
}
this.referenceDataId = qParams.reference;
if (Object.values(ValidLayoutParams).includes(qParams.layout)) {
this.layout = qParams.layout;
this.accountCreateOnly = false;
}
if (this.trialFlowOrgs.includes(qParams.org)) {
this.org = qParams.org;
this.orgLabel = this.titleCasePipe.transform(this.orgDisplayName);
this.useTrialStepper = true;
this.referenceData.flow = qParams.org;
if (this.org === ValidOrgParams.families) {
this.plan = PlanType.FamiliesAnnually;
this.productTier = ProductTierType.Families;
} else if (this.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
this.productTier = ProductTierType.TeamsStarter;
} else if (this.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
this.productTier = ProductTierType.Teams;
} else if (this.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
this.productTier = ProductTierType.Enterprise;
}
} else if (this.routeFlowOrgs.includes(qParams.org)) {
this.referenceData.flow = qParams.org;
const route = this.router.createUrlTree(["create-organization"], {
queryParams: { plan: qParams.org },
});
this.routerService.setPreviousUrl(route.toString());
}
// Are they coming from an email for sponsoring a families organization
// After logging in redirect them to setup the families sponsorship
this.setupFamilySponsorship(qParams.sponsorshipToken);
this.referenceData.initiationPath = this.accountCreateOnly
? "Registration form"
: "Password Manager trial from marketing website";
});
// If there's a deep linked org invite, use it to get the password policies
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
this.orgInfoFormGroup.controls.name.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.orgInfoFormGroup.controls.name.markAsTouched();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
stepSelectionChange(event: StepperSelectionEvent) {
// Set org info sub label
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel =
"Enter your " +
this.titleCasePipe.transform(this.orgDisplayName) +
" organization information";
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
//set billing sub label
if (event.selectedIndex === 2) {
this.billingSubLabel = this.i18nService.t("billingTrialSubLabel");
}
}
async createOrganizationOnTrial() {
this.loading = true;
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.get("name").value,
billingEmail: this.orgInfoFormGroup.get("email").value,
initiationPath: "Password Manager trial from marketing website",
};
const plan: PlanInformation = {
type: this.plan,
passwordManagerSeats: 1,
};
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization,
plan,
});
this.orgId = response?.id;
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
this.loading = false;
this.verticalStepper.next();
}
createdAccount(email: string) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);
this.verticalStepper.next();
}
billingSuccess(event: any) {
this.orgId = event?.orgId;
this.billingSubLabel = event?.subLabelText;
this.verticalStepper.next();
}
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
navigateToOrgVault() {
// 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.router.navigate(["organizations", this.orgId, "vault"]);
}
navigateToOrgInvite() {
// 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.router.navigate(["organizations", this.orgId, "members"]);
}
previousStep() {
this.verticalStepper.previous();
}
get orgDisplayName() {
if (this.org === "teamsStarter") {
return "Teams Starter";
}
return this.org;
}
get freeTrialText() {
const translationKey =
this.layout === this.layouts.secretsManager
? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
: "startYour7DayFreeTrialOfBitwardenFor";
return this.i18nService.t(translationKey, this.org);
}
get trialOrganizationType(): TrialOrganizationType {
switch (this.productTier) {
case ProductTierType.Free:
return null;
default:
return this.productTier;
}
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
return;
}
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@@ -6,7 +6,6 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular";
import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { RegisterFormModule } from "../../auth/register-form/register-form.module";
import { TaxInfoComponent } from "../../billing";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { SecretsManagerTrialFreeStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
@@ -39,7 +38,6 @@ import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@NgModule({
@@ -48,7 +46,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
CdkStepperModule,
VerticalStepperModule,
FormFieldModule,
RegisterFormModule,
OrganizationCreateModule,
EnvironmentSelectorModule,
TaxInfoComponent,
@@ -56,7 +53,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
InputPasswordComponent,
],
declarations: [
TrialInitiationComponent,
CompleteTrialInitiationComponent,
EnterpriseContentComponent,
TeamsContentComponent,
@@ -87,7 +83,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
SecretsManagerTrialFreeStepperComponent,
SecretsManagerTrialPaidStepperComponent,
],
exports: [TrialInitiationComponent, CompleteTrialInitiationComponent],
exports: [CompleteTrialInitiationComponent],
providers: [TitleCasePipe],
})
export class TrialInitiationModule {}

View File

@@ -20,7 +20,6 @@ import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from
import { HintComponent } from "../auth/hint.component";
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
import { RegisterFormModule } from "../auth/register-form/register-form.module";
import { RemovePasswordComponent } from "../auth/remove-password.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { AccountComponent } from "../auth/settings/account/account.component";
@@ -90,7 +89,6 @@ import { SharedModule } from "./shared.module";
@NgModule({
imports: [
SharedModule,
RegisterFormModule,
ProductSwitcherModule,
UserVerificationModule,
ChangeKdfModule,

View File

@@ -42,6 +42,7 @@ export class VaultCipherRowComponent implements OnInit {
@Input() collections: CollectionView[];
@Input() viewingOrgVault: boolean;
@Input() canEditCipher: boolean;
@Input() canAssignCollections: boolean;
@Input() canManageCollection: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@@ -101,7 +102,7 @@ export class VaultCipherRowComponent implements OnInit {
}
protected get showAssignToCollections() {
return this.organizations?.length && this.canEditCipher && !this.cipher.isDeleted;
return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted;
}
protected get showClone() {
@@ -208,6 +209,6 @@ export class VaultCipherRowComponent implements OnInit {
return true; // Always show checkbox in individual vault or for non-org items
}
return this.organization.canEditAllCiphers || this.cipher.edit;
return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword);
}
}

View File

@@ -144,6 +144,7 @@
[collections]="allCollections"
[checked]="selection.isSelected(item)"
[canEditCipher]="canEditCipher(item.cipher)"
[canAssignCollections]="canAssignCollections(item.cipher)"
[canManageCollection]="canManageCollection(item.cipher)"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"

View File

@@ -236,6 +236,13 @@ export class VaultItemsComponent {
return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit;
}
protected canAssignCollections(cipher: CipherView) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
return (
(organization?.canEditAllCiphers && this.viewingOrgVault) || cipher.canAssignToCollections
);
}
protected canManageCollection(cipher: CipherView) {
// If the cipher is not part of an organization (personal item), user can manage it
if (cipher.organizationId == null) {
@@ -461,7 +468,7 @@ export class VaultItemsComponent {
private allCiphersHaveEditAccess(): boolean {
return this.selection.selected
.filter(({ cipher }) => cipher)
.every(({ cipher }) => cipher?.edit);
.every(({ cipher }) => cipher?.edit && cipher?.viewPassword);
}
private getUniqueOrganizationIds(): Set<string> {

View File

@@ -4,10 +4,8 @@ import { Observable } from "rxjs";
import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
CipherTypeFilter,

View File

@@ -274,6 +274,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
folderCopy.icon = "bwi-folder";
folderCopy.fullName = f.name; // save full folder name before separating it into parts
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
});

View File

@@ -1,10 +1,8 @@
import { CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { FolderView } from "@bitwarden/common/src/vault/models/view/folder.view";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
export type CipherStatus = "all" | "favorites" | "trash" | CipherType;
@@ -12,5 +10,13 @@ export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: str
export type CollectionFilter = CollectionAdminView & {
icon: string;
};
export type FolderFilter = FolderView & { icon: string };
export type FolderFilter = FolderView & {
icon: string;
/**
* Full folder name.
*
* Used for when the folder `name` property is be separated into parts.
*/
fullName?: string;
};
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };

View File

@@ -77,6 +77,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
CipherFormConfig,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
@@ -118,7 +120,6 @@ import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component";
import { VaultBannersComponent } from "./vault-banners/vault-banners.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
@@ -607,20 +608,24 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.filterComponent.filters?.organizationFilter?.action(orgNode);
}
addFolder = async (): Promise<void> => {
openFolderAddEditDialog(this.dialogService);
addFolder = (): void => {
AddEditFolderDialogComponent.open(this.dialogService);
};
editFolder = async (folder: FolderFilter): Promise<void> => {
const dialog = openFolderAddEditDialog(this.dialogService, {
data: {
folderId: folder.id,
const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, {
editFolderConfig: {
// Shallow copy is used so the original folder object is not modified
folder: {
...folder,
name: folder.fullName ?? folder.name, // If the filter has a fullName populated, use that as the editable name
},
},
});
const result = await lastValueFrom(dialog.closed);
const result = await lastValueFrom(dialogRef.closed);
if (result === FolderAddEditDialogResult.Deleted) {
if (result === AddEditFolderDialogResult.Deleted) {
await this.router.navigate([], {
queryParams: { folderId: null },
queryParamsHandling: "merge",

View File

@@ -15,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -25,13 +26,8 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { CipherViewComponent } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service";

View File

@@ -7,13 +7,10 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { Account } from "../../../../../../../libs/importer/src/importers/lastpass/access/models";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
import { AdminConsoleCipherFormConfigService } from "./admin-console-cipher-form-config.service";
@@ -85,7 +82,14 @@ describe("AdminConsoleCipherFormConfigService", () => {
{ provide: CipherService, useValue: { get: getCipher } },
{
provide: AccountService,
useValue: { activeAccount$: new BehaviorSubject<Account>(new Account()) },
useValue: {
activeAccount$: new BehaviorSubject<Account>({
id: "123-456-789" as UserId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
}),
},
},
],
});

View File

@@ -15,14 +15,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherFormConfig, CipherFormConfigService, CipherFormMode } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import {
CipherFormConfig,
CipherFormConfigService,
CipherFormMode,
} from "../../../../../../../libs/vault/src/cipher-form/abstractions/cipher-form-config.service";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
/** Admin Console implementation of the `CipherFormConfigService`. */

View File

@@ -1,11 +1,9 @@
import { Injectable } from "@angular/core";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { ViewPasswordHistoryService } from "../../../../../../libs/common/src/vault/abstractions/view-password-history.service";
import { openPasswordHistoryDialog } from "../individual-vault/password-history.component";
/**

View File

@@ -485,6 +485,18 @@
"editFolder": {
"message": "Edit folder"
},
"newFolder": {
"message": "New folder"
},
"folderName": {
"message": "Folder name"
},
"folderHintText": {
"message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums"
},
"deleteFolderPermanently": {
"message": "Are you sure you want to permanently delete this folder?"
},
"baseDomain": {
"message": "Base domain",
"description": "Domain name. Example: website.com"
@@ -749,15 +761,6 @@
"itemName": {
"message": "Item name"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"ex": {
"message": "ex.",
"description": "Short abbreviation for 'example'."
@@ -10143,6 +10146,15 @@
"descriptorCode": {
"message": "Descriptor code"
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
"collections": {
"content": "$1",
"example": "Work, Personal"
}
}
},
"importantNotice": {
"message": "Important notice"
},
@@ -10333,5 +10345,45 @@
"example": "Acme c"
}
}
},
"seatsRemaining": {
"message": "You have $REMAINING$ seats remaining out of $TOTAL$ seats assigned to this organization. Contact your provider to manage your subscription.",
"placeholders": {
"remaining": {
"content": "$1",
"example": "5"
},
"total": {
"content": "$2",
"example": "10"
}
}
},
"existingOrganization": {
"message": "Existing organization"
},
"selectOrganizationProviderPortal": {
"message": "Select an organization to add to your Provider Portal."
},
"noOrganizations": {
"message": "There are no organizations to list"
},
"yourProviderSubscriptionCredit": {
"message": "Your provider subscription will receive a credit for any remaining time in the organization's subscription."
},
"doYouWantToAddThisOrg": {
"message": "Do you want to add this organization to $PROVIDER$?",
"placeholders": {
"provider": {
"content": "$1",
"example": "Cool MSP"
}
}
},
"addedExistingOrganization": {
"message": "Added existing organization"
},
"assignedExceedsAvailable": {
"message": "Assigned seats exceed available seats."
}
}

View File

@@ -17,6 +17,7 @@ import {
ProviderSubscriptionComponent,
ProviderSubscriptionStatusComponent,
} from "../../billing/providers";
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
@@ -63,6 +64,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
SetupProviderComponent,
UserAddEditComponent,
AddEditMemberDialogComponent,
AddExistingOrganizationDialogComponent,
CreateClientDialogComponent,
ManageClientNameDialogComponent,
ManageClientSubscriptionDialogComponent,

View File

@@ -1,8 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { switchMap } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
@@ -10,6 +13,8 @@ import { PlanType } from "@bitwarden/common/billing/enums";
import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
@@ -23,6 +28,8 @@ export class WebProviderService {
private i18nService: I18nService,
private encryptService: EncryptService,
private billingApiService: BillingApiServiceAbstraction,
private stateProvider: StateProvider,
private providerApiService: ProviderApiServiceAbstraction,
) {}
async addOrganizationToProvider(providerId: string, organizationId: string) {
@@ -40,6 +47,22 @@ export class WebProviderService {
return response;
}
async addOrganizationToProviderVNext(providerId: string, organizationId: string): Promise<void> {
const orgKey = await firstValueFrom(
this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]),
),
);
const providerKey = await this.keyService.getProviderKey(providerId);
const encryptedOrgKey = await this.encryptService.encrypt(orgKey.key, providerKey);
await this.providerApiService.addOrganizationToProvider(providerId, {
key: encryptedOrgKey.encryptedString,
organizationId,
});
await this.syncService.fullSync(true);
}
async createClientOrganization(
providerId: string,
name: string,

View File

@@ -0,0 +1,73 @@
<bit-dialog [loading]="loading">
<span bitDialogTitle>
{{ "addExistingOrganization" | i18n }}
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="!selectedOrganization; else organizationSelected">
<p>{{ "selectOrganizationProviderPortal" | i18n }}</p>
<bit-table>
<ng-container header>
<tr>
<th colspan="2" bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "assigned" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr
bitRow
*ngFor="let addable of addableOrganizations"
[ngClass]="{ 'tw-text-muted': addable.disabled }"
>
<td bitCell class="tw-w-8">
<bit-avatar [text]="addable.name" [id]="addable.id" size="small"></bit-avatar>
</td>
<td bitCell>
{{ addable.name }}
<div *ngIf="addable.disabled" class="tw-text-xs">
{{ "assignedExceedsAvailable" | i18n }}
</div>
</td>
<td bitCell>{{ addable.seats }}</td>
<td bitCell class="tw-text-right">
<button
type="button"
bitButton
buttonType="secondary"
[disabled]="addable.disabled"
(click)="selectOrganization(addable.id)"
>
{{ "next" | i18n }}
</button>
</td>
</tr>
</ng-template>
</bit-table>
<p *ngIf="addableOrganizations.length === 0" class="tw-text-muted tw-mt-2">
{{ "noOrganizations" | i18n }}
</p>
</ng-container>
<ng-template #organizationSelected>
<p>{{ "yourProviderSubscriptionCredit" | i18n }}</p>
<p>{{ "doYouWantToAddThisOrg" | i18n: dialogParams.provider.name }}</p>
<div class="tw-flex tw-flex-col">
<div>{{ "organization" | i18n }}: {{ selectedOrganization.name }}</div>
<div>{{ "billingPlan" | i18n }}: {{ selectedOrganization.plan }}</div>
<div>{{ "assignedSeats" | i18n }}: {{ selectedOrganization.seats }}</div>
</div>
</ng-template>
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="selectedOrganization"
type="button"
bitButton
buttonType="primary"
[bitAction]="addExistingOrganization"
>
{{ "addOrganization" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,82 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
export type AddExistingOrganizationDialogParams = {
provider: Provider;
};
export enum AddExistingOrganizationDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
@Component({
templateUrl: "./add-existing-organization-dialog.component.html",
})
export class AddExistingOrganizationDialogComponent implements OnInit {
protected loading: boolean = true;
addableOrganizations: AddableOrganizationResponse[] = [];
selectedOrganization?: AddableOrganizationResponse;
protected readonly ResultType = AddExistingOrganizationDialogResultType;
constructor(
@Inject(DIALOG_DATA) protected dialogParams: AddExistingOrganizationDialogParams,
private dialogRef: DialogRef<AddExistingOrganizationDialogResultType>,
private i18nService: I18nService,
private providerApiService: ProviderApiServiceAbstraction,
private toastService: ToastService,
private webProviderService: WebProviderService,
) {}
async ngOnInit() {
this.addableOrganizations = await this.providerApiService.getProviderAddableOrganizations(
this.dialogParams.provider.id,
);
this.loading = false;
}
addExistingOrganization = async (): Promise<void> => {
if (this.selectedOrganization) {
await this.webProviderService.addOrganizationToProviderVNext(
this.dialogParams.provider.id,
this.selectedOrganization.id,
);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("addedExistingOrganization"),
});
this.dialogRef.close(this.ResultType.Submitted);
}
};
selectOrganization(organizationId: string) {
this.selectedOrganization = this.addableOrganizations.find(
(organization) => organization.id === organizationId,
);
}
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<
AddExistingOrganizationDialogParams,
DialogRef<AddExistingOrganizationDialogResultType>
>,
) =>
dialogService.open<
AddExistingOrganizationDialogResultType,
AddExistingOrganizationDialogParams
>(AddExistingOrganizationDialogComponent, dialogConfig);
}

View File

@@ -1,9 +1,39 @@
<app-header>
<bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search>
<a type="button" bitButton *ngIf="isProviderAdmin" buttonType="primary" (click)="createClient()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
<ng-container *ngIf="addExistingOrgsFromProviderPortal$ | async; else addExistingOrgsDisabled">
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="clientMenu"
appA11yTitle="{{ 'add' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "add" | i18n }}
</button>
<bit-menu #clientMenu>
<button type="button" bitMenuItem (click)="createClient()">
<i aria-hidden="true" class="bwi bwi-business"></i>
{{ "newClient" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addExistingOrganization()">
<i aria-hidden="true" class="bwi bwi-sitemap"></i>
{{ "existingOrganization" | i18n }}
</button>
</bit-menu>
</ng-container>
<ng-template #addExistingOrgsDisabled>
<a
type="button"
bitButton
*ngIf="isProviderAdmin"
buttonType="primary"
(click)="createClient()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
</ng-template>
</app-header>
<ng-container *ngIf="loading">

View File

@@ -11,6 +11,8 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
@@ -25,6 +27,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
import {
AddExistingOrganizationDialogComponent,
AddExistingOrganizationDialogResultType,
} from "./add-existing-organization-dialog.component";
import {
CreateClientDialogResultType,
openCreateClientDialog,
@@ -62,6 +68,9 @@ export class ManageClientsComponent {
protected searchControl = new FormControl("", { nonNullable: true });
protected plans: PlanResponse[] = [];
protected addExistingOrgsFromProviderPortal$ = this.configService.getFeatureFlag$(
FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal,
);
constructor(
private billingApiService: BillingApiServiceAbstraction,
@@ -73,6 +82,7 @@ export class ManageClientsComponent {
private toastService: ToastService,
private validationService: ValidationService,
private webProviderService: WebProviderService,
private configService: ConfigService,
) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search);
@@ -111,19 +121,30 @@ export class ManageClientsComponent {
async load() {
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId))
.data;
this.dataSource.data = clients;
this.dataSource.data = (
await this.billingApiService.getProviderClientOrganizations(this.providerId)
).data;
this.plans = (await this.billingApiService.getPlans()).data;
this.loading = false;
}
addExistingOrganization = async () => {
if (this.provider) {
const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, {
data: {
provider: this.provider,
},
});
const result = await lastValueFrom(reference.closed);
if (result === AddExistingOrganizationDialogResultType.Submitted) {
await this.load();
}
}
};
createClient = async () => {
const reference = openCreateClientDialog(this.dialogService, {
data: {

View File

@@ -0,0 +1,34 @@
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
/**
* Request type for creating tasks.
* @property cipherId - Optional. The ID of the cipher to create the task for.
* @property type - The type of task to create. Currently defined as "updateAtRiskCredential".
*/
export type CreateTasksRequest = Readonly<{
cipherId?: CipherId;
type: SecurityTaskType.UpdateAtRiskCredential;
}>;
export abstract class AdminTaskService {
/**
* Retrieves all tasks for a given organization.
* @param organizationId - The ID of the organization to retrieve tasks for.
* @param status - Optional. The status of the tasks to retrieve.
*/
abstract getAllTasks(
organizationId: OrganizationId,
status?: SecurityTaskStatus | undefined,
): Promise<SecurityTask[]>;
/**
* Creates multiple tasks for a given organization and sends out notifications to applicable users.
* @param organizationId - The ID of the organization to create tasks for.
* @param tasks - The tasks to create.
*/
abstract bulkCreateTasks(
organizationId: OrganizationId,
tasks: CreateTasksRequest[],
): Promise<void>;
}

View File

@@ -0,0 +1,65 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
import { CreateTasksRequest } from "./abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "./default-admin-task.service";
describe("DefaultAdminTaskService", () => {
let defaultAdminTaskService: DefaultAdminTaskService;
let apiService: MockProxy<ApiService>;
beforeEach(() => {
apiService = mock<ApiService>();
defaultAdminTaskService = new DefaultAdminTaskService(apiService);
});
describe("getAllTasks", () => {
it("should call the api service with the correct parameters with status", async () => {
const organizationId = "orgId" as OrganizationId;
const status = SecurityTaskStatus.Pending;
const expectedUrl = `/tasks/organization?organizationId=${organizationId}&status=0`;
await defaultAdminTaskService.getAllTasks(organizationId, status);
expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true);
});
it("should call the api service with the correct parameters without status", async () => {
const organizationId = "orgId" as OrganizationId;
const expectedUrl = `/tasks/organization?organizationId=${organizationId}`;
await defaultAdminTaskService.getAllTasks(organizationId);
expect(apiService.send).toHaveBeenCalledWith("GET", expectedUrl, null, true, true);
});
});
describe("bulkCreateTasks", () => {
it("should call the api service with the correct parameters", async () => {
const organizationId = "orgId" as OrganizationId;
const tasks: CreateTasksRequest[] = [
{
cipherId: "cipherId-1" as CipherId,
type: SecurityTaskType.UpdateAtRiskCredential,
},
{
cipherId: "cipherId-2" as CipherId,
type: SecurityTaskType.UpdateAtRiskCredential,
},
];
await defaultAdminTaskService.bulkCreateTasks(organizationId, tasks);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
`/tasks/${organizationId}/bulk-create`,
tasks,
true,
true,
);
});
});
});

View File

@@ -0,0 +1,48 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
SecurityTask,
SecurityTaskData,
SecurityTaskResponse,
SecurityTaskStatus,
} from "@bitwarden/vault";
import { AdminTaskService, CreateTasksRequest } from "./abstractions/admin-task.abstraction";
@Injectable()
export class DefaultAdminTaskService implements AdminTaskService {
constructor(private apiService: ApiService) {}
async getAllTasks(
organizationId: OrganizationId,
status?: SecurityTaskStatus | undefined,
): Promise<SecurityTask[]> {
const queryParams = new URLSearchParams();
queryParams.append("organizationId", organizationId);
if (status !== undefined) {
queryParams.append("status", status.toString());
}
const r = await this.apiService.send(
"GET",
`/tasks/organization?${queryParams.toString()}`,
null,
true,
true,
);
const response = new ListResponse(r, SecurityTaskResponse);
return response.data.map((d) => new SecurityTask(new SecurityTaskData(d)));
}
async bulkCreateTasks(
organizationId: OrganizationId,
tasks: CreateTasksRequest[],
): Promise<void> {
await this.apiService.send("POST", `/tasks/${organizationId}/bulk-create`, tasks, true, true);
}
}

View File

@@ -11,6 +11,8 @@ import rxjs from "eslint-plugin-rxjs";
import angularRxjs from "eslint-plugin-rxjs-angular";
import storybook from "eslint-plugin-storybook";
import platformPlugins from "./libs/eslint/platform/index.mjs";
export default tseslint.config(
...storybook.configs["flat/recommended"],
{
@@ -28,6 +30,7 @@ export default tseslint.config(
plugins: {
rxjs: rxjs,
"rxjs-angular": angularRxjs,
"@bitwarden/platform": platformPlugins,
},
languageOptions: {
parserOptions: {
@@ -66,7 +69,7 @@ export default tseslint.config(
"@angular-eslint/no-outputs-metadata-property": 0,
"@angular-eslint/use-lifecycle-interface": "error",
"@angular-eslint/use-pipe-transform-interface": 0,
"@bitwarden/platform/required-using": "error",
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
"@typescript-eslint/no-floating-promises": "error",

View File

@@ -30,6 +30,7 @@ module.exports = {
"<rootDir>/libs/billing/jest.config.js",
"<rootDir>/libs/common/jest.config.js",
"<rootDir>/libs/components/jest.config.js",
"<rootDir>/libs/eslint/jest.config.js",
"<rootDir>/libs/tools/export/vault-export/vault-export-core/jest.config.js",
"<rootDir>/libs/tools/generator/core/jest.config.js",
"<rootDir>/libs/tools/generator/components/jest.config.js",

View File

@@ -195,7 +195,7 @@ export class BaseLoginDecryptionOptionsComponentV1 implements OnInit, OnDestroy
async loadNewUserData() {
const autoEnrollStatus$ = defer(() =>
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(),
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId),
).pipe(
switchMap((organizationIdentifier) => {
if (organizationIdentifier == undefined) {

View File

@@ -47,7 +47,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
resetPasswordAutoEnroll = false;
onSuccessfulChangePassword: () => Promise<void>;
successRoute = "vault";
userId: UserId;
activeUserId: UserId;
forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
ForceSetPasswordReason = ForceSetPasswordReason;
@@ -96,10 +96,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
await this.syncService.fullSync(true);
this.syncLoading = false;
this.userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
this.forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(this.userId),
this.masterPasswordService.forceSetPasswordReason$(this.activeUserId),
);
this.route.queryParams
@@ -111,7 +111,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
} else {
// Try to get orgSsoId from state as fallback
// Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario.
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier();
return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId);
}
}),
filter((orgSsoId) => orgSsoId != null),
@@ -167,10 +167,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one
const existingUserPrivateKey = (await firstValueFrom(
this.keyService.userPrivateKey$(this.userId),
this.keyService.userPrivateKey$(this.activeUserId),
)) as Uint8Array;
const existingUserPublicKey = await firstValueFrom(
this.keyService.userPublicKey$(this.userId),
this.keyService.userPublicKey$(this.activeUserId),
);
if (existingUserPrivateKey != null && existingUserPublicKey != null) {
const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey);
@@ -217,7 +217,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
this.orgId,
this.userId,
this.activeUserId,
resetRequest,
);
});
@@ -260,7 +260,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.None,
this.userId,
this.activeUserId,
);
// User now has a password so update account decryption options in state
@@ -269,9 +269,9 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(this.userId, this.kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, this.userId);
await this.keyService.setUserKey(userKey[0], this.userId);
await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId);
await this.keyService.setUserKey(userKey[0], this.activeUserId);
// Set private key only for new JIT provisioned users in MP encryption orgs
// Existing TDE users will have private key set on sync or on login
@@ -280,7 +280,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
this.forceSetPasswordReason !=
ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission
) {
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.userId);
await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId);
}
const localMasterKeyHash = await this.keyService.hashMasterKey(
@@ -288,6 +288,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { first } from "rxjs/operators";
@@ -27,6 +28,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@@ -55,6 +57,7 @@ export class SsoComponent implements OnInit {
protected redirectUri: string;
protected state: string;
protected codeChallenge: string;
protected activeUserId: UserId;
constructor(
protected ssoLoginService: SsoLoginServiceAbstraction,
@@ -74,7 +77,11 @@ export class SsoComponent implements OnInit {
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected accountService: AccountService,
protected toastService: ToastService,
) {}
) {
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
}
async ngOnInit() {
// eslint-disable-next-line rxjs/no-async-subscribe
@@ -226,7 +233,10 @@ export class SsoComponent implements OnInit {
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
orgSsoIdentifier,
this.activeUserId,
);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)

View File

@@ -69,7 +69,7 @@
</a>
</div>
<div class="text-center">
<a bitLink href="#" appStopClick (click)="selectOtherTwofactorMethod()">{{
<a bitLink href="#" appStopClick (click)="selectOtherTwoFactorMethod()">{{
"useAnotherTwoStepMethod" | i18n
}}</a>
</div>

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, NavigationExtras, Router, RouterLink } from "@angular/router";
import { Subject, takeUntil, lastValueFrom, first, firstValueFrom } from "rxjs";
@@ -31,6 +32,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -126,6 +128,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
protected changePasswordRoute = "set-password";
protected forcePasswordResetRoute = "update-temp-password";
protected successRoute = "vault";
protected activeUserId: UserId;
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
@@ -148,6 +151,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
}
async ngOnInit() {
@@ -214,7 +221,7 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
}
}
async selectOtherTwofactorMethod() {
async selectOtherTwoFactorMethod() {
const dialogRef = TwoFactorOptionsComponent.open(this.dialogService);
const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed);
if (response.result === TwoFactorOptionsDialogResult.Provider) {
@@ -262,7 +269,10 @@ export class TwoFactorAuthComponent extends CaptchaProtectedComponent implements
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component
// - Browser SSO on extension open
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
this.orgIdentifier,
this.activeUserId,
);
this.loginEmailService.clearValues();
// note: this flow affects both TDE & standard users

View File

@@ -35,6 +35,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@@ -73,6 +74,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected successRoute = "vault";
protected twoFactorTimeoutRoute = "authentication-timeout";
protected activeUserId: UserId;
get isDuoProvider(): boolean {
return (
this.selectedProviderType === TwoFactorProviderType.Duo ||
@@ -102,8 +105,13 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
this.accountService.activeAccount$.pipe(takeUntilDestroyed()).subscribe((account) => {
this.activeUserId = account?.id;
});
// Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired
this.loginStrategyService.authenticationSessionTimeout$
.pipe(takeUntilDestroyed())
@@ -287,7 +295,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
// Save off the OrgSsoIdentifier for use in the TDE flows
// - TDE login decryption options component
// - Browser SSO on extension open
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
this.orgIdentifier,
this.activeUserId,
);
this.loginEmailService.clearValues();
// note: this flow affects both TDE & standard users

View File

@@ -296,7 +296,7 @@ import {
DefaultUserAsymmetricKeysRegenerationApiService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import { PasswordRepromptService } from "@bitwarden/vault";
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
@@ -306,9 +306,6 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../vault/src/services/new-device-verification-notice.service";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -799,7 +796,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: SsoLoginServiceAbstraction,
useClass: SsoLoginService,
deps: [StateProvider],
deps: [StateProvider, LogService],
}),
safeProvider({
provide: STATE_FACTORY,

View File

@@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";
import { NewDeviceVerificationNoticeGuard } from "./new-device-verification-notice.guard";

View File

@@ -8,10 +8,8 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { NewDeviceVerificationNoticeService } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { NewDeviceVerificationNoticeService } from "../../../../vault/src/services/new-device-verification-notice.service";
import { VaultProfileService } from "../services/vault-profile.service";
export const NewDeviceVerificationNoticeGuard: CanActivateFn = async (

View File

@@ -16,14 +16,11 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state";
import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "../../abstractions/deprecated-vault-filter.service";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { COLLAPSED_GROUPINGS } from "./../../../../../common/src/vault/services/key-state/collapsed-groupings.state";
const NestingDelimiter = "/";
@Injectable()

View File

@@ -202,7 +202,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
});
const autoEnrollStatus$ = defer(() =>
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(),
this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeAccountId),
).pipe(
switchMap((organizationIdentifier) => {
if (organizationIdentifier == undefined) {

View File

@@ -36,6 +36,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import {
AsyncActionsModule,
ButtonModule,
@@ -89,6 +90,7 @@ export class SsoComponent implements OnInit {
protected state: string | undefined;
protected codeChallenge: string | undefined;
protected clientId: SsoClientType | undefined;
protected activeUserId: UserId | undefined;
formPromise: Promise<AuthResult> | undefined;
initiateSsoFormPromise: Promise<SsoPreValidateResponse> | undefined;
@@ -130,6 +132,8 @@ export class SsoComponent implements OnInit {
}
async ngOnInit() {
this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const qParams: QueryParams = await firstValueFrom(this.route.queryParams);
// This if statement will pass on the second portion of the SSO flow
@@ -384,7 +388,10 @@ export class SsoComponent implements OnInit {
// - TDE login decryption options component
// - Browser SSO on extension open
// Note: you cannot set this in state before 2FA b/c there won't be an account in state.
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier);
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(
orgSsoIdentifier,
this.activeUserId,
);
// Users enrolled in admin acct recovery can be forced to set a new password after
// having the admin set a temp password for them (affects TDE & standard users)

View File

@@ -120,7 +120,7 @@
<div class="tw-mb-6" *ngIf="sentInitialCode">
{{ "enterVerificationCodeSentToEmail" | i18n }}
<p class="mb-0">
<p class="tw-mb-0">
<button bitLink type="button" linkType="primary" (click)="requestOTP()">
{{ "resendCode" | i18n }}
</button>

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
@@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction {
request: ProviderVerifyRecoverDeleteRequest,
) => Promise<any>;
deleteProvider: (id: string) => Promise<void>;
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
addOrganizationToProvider: (
providerId: string,
request: {
key: string;
organizationId: string;
},
) => Promise<void>;
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class AddableOrganizationResponse extends BaseResponse {
id: string;
plan: string;
name: string;
seats: number;
disabled: boolean;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("id");
this.plan = this.getResponseProperty("plan");
this.name = this.getResponseProperty("name");
this.seats = this.getResponseProperty("seats");
this.disabled = this.getResponseProperty("disabled");
}
}

View File

@@ -1,3 +1,5 @@
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ApiService } from "../../../abstractions/api.service";
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
@@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
async deleteProvider(id: string): Promise<void> {
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
}
async getProviderAddableOrganizations(
providerId: string,
): Promise<AddableOrganizationResponse[]> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/clients/addable",
null,
true,
true,
);
return response.map((data: any) => new AddableOrganizationResponse(data));
}
addOrganizationToProvider(
providerId: string,
request: {
key: string;
organizationId: string;
},
): Promise<void> {
return this.apiService.send(
"POST",
"/providers/" + providerId + "/clients/existing",
request,
true,
false,
);
}
}

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
export abstract class SsoLoginServiceAbstraction {
/**
* Gets the code verifier used for SSO.
@@ -74,12 +76,16 @@ export abstract class SsoLoginServiceAbstraction {
* Gets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
* @param userId The user id for retrieving the org identifier state.
*/
getActiveUserOrganizationSsoIdentifier: () => Promise<string>;
getActiveUserOrganizationSsoIdentifier: (userId: UserId) => Promise<string>;
/**
* Sets the value of the active user's organization sso identifier.
*
* This should only be used post successful SSO login once the user is initialized.
*/
setActiveUserOrganizationSsoIdentifier: (organizationIdentifier: string) => Promise<void>;
setActiveUserOrganizationSsoIdentifier: (
organizationIdentifier: string,
userId: UserId | undefined,
) => Promise<void>;
}

View File

@@ -0,0 +1,94 @@
import { mock, MockProxy } from "jest-mock-extended";
import {
CODE_VERIFIER,
GLOBAL_ORGANIZATION_SSO_IDENTIFIER,
SSO_EMAIL,
SSO_STATE,
SsoLoginService,
USER_ORGANIZATION_SSO_IDENTIFIER,
} from "@bitwarden/common/auth/services/sso-login.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
describe("SSOLoginService ", () => {
let sut: SsoLoginService;
let accountService: FakeAccountService;
let mockSingleUserStateProvider: FakeStateProvider;
let mockLogService: MockProxy<LogService>;
let userId: UserId;
beforeEach(() => {
jest.clearAllMocks();
userId = Utils.newGuid() as UserId;
accountService = mockAccountServiceWith(userId);
mockSingleUserStateProvider = new FakeStateProvider(accountService);
mockLogService = mock<LogService>();
sut = new SsoLoginService(mockSingleUserStateProvider, mockLogService);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
it("gets and sets code verifier", async () => {
const codeVerifier = "test-code-verifier";
await sut.setCodeVerifier(codeVerifier);
mockSingleUserStateProvider.getGlobal(CODE_VERIFIER);
const result = await sut.getCodeVerifier();
expect(result).toBe(codeVerifier);
});
it("gets and sets SSO state", async () => {
const ssoState = "test-sso-state";
await sut.setSsoState(ssoState);
mockSingleUserStateProvider.getGlobal(SSO_STATE);
const result = await sut.getSsoState();
expect(result).toBe(ssoState);
});
it("gets and sets organization SSO identifier", async () => {
const orgIdentifier = "test-org-identifier";
await sut.setOrganizationSsoIdentifier(orgIdentifier);
mockSingleUserStateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getOrganizationSsoIdentifier();
expect(result).toBe(orgIdentifier);
});
it("gets and sets SSO email", async () => {
const email = "test@example.com";
await sut.setSsoEmail(email);
mockSingleUserStateProvider.getGlobal(SSO_EMAIL);
const result = await sut.getSsoEmail();
expect(result).toBe(email);
});
it("gets and sets active user organization SSO identifier", async () => {
const userId = Utils.newGuid() as UserId;
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, userId);
mockSingleUserStateProvider.getUser(userId, USER_ORGANIZATION_SSO_IDENTIFIER);
const result = await sut.getActiveUserOrganizationSsoIdentifier(userId);
expect(result).toBe(orgIdentifier);
});
it("logs error when setting active user organization SSO identifier with undefined userId", async () => {
const orgIdentifier = "test-active-org-identifier";
await sut.setActiveUserOrganizationSsoIdentifier(orgIdentifier, undefined);
expect(mockLogService.warning).toHaveBeenCalledWith(
"Tried to set a user organization sso identifier with an undefined user id.",
);
});
});

Some files were not shown because too many files have changed in this diff Show More