1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

Merge branch 'main' of github.com:bitwarden/clients

This commit is contained in:
gbubemismith
2024-05-10 13:00:28 -04:00
52 changed files with 1483 additions and 943 deletions

View File

@@ -3035,6 +3035,9 @@
"accountSecurity": { "accountSecurity": {
"message": "Account security" "message": "Account security"
}, },
"notifications": {
"message": "Notifications"
},
"appearance": { "appearance": {
"message": "Appearance" "message": "Appearance"
}, },

View File

@@ -26,7 +26,9 @@ export const fido2AuthGuard: CanActivateFn = async (
const authStatus = await authService.getAuthStatus(); const authStatus = await authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Locked) { if (authStatus === AuthenticationStatus.Locked) {
routerService.setPreviousUrl(state.url); // Appending fromLock=true to the query params to indicate that the user is being redirected from the lock screen, this is used for user verification.
const previousUrl = `${state.url}&fromLock=true`;
routerService.setPreviousUrl(previousUrl);
return router.createUrlTree(["/lock"], { queryParams: route.queryParams }); return router.createUrlTree(["/lock"], { queryParams: route.queryParams });
} }

View File

@@ -1,7 +1,7 @@
<form #form (ngSubmit)="submit()"> <form #form (ngSubmit)="submit()">
<header> <header>
<div class="left"> <div class="left">
<button type="button" routerLink="/tabs/settings"> <button type="button" routerLink="/notifications">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span> <span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span> <span>{{ "back" | i18n }}</span>
</button> </button>

View File

@@ -8,8 +8,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { enableAccountSwitching } from "../../platform/flags"; import { enableAccountSwitching } from "../../../platform/flags";
interface ExcludedDomain { interface ExcludedDomain {
uri: string; uri: string;

View File

@@ -0,0 +1,89 @@
<header>
<div class="left">
<button type="button" routerLink="/tabs/settings">
<span class="header-icon"><i class="bwi bwi-angle-left" aria-hidden="true"></i></span>
<span>{{ "back" | i18n }}</span>
</button>
</div>
<h1 class="center">
<span class="title">{{ "notifications" | i18n }}</span>
</h1>
<div class="right">
<app-pop-out></app-pop-out>
</div>
</header>
<main tabindex="-1">
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="use-passkeys">{{ "enableUsePasskeys" | i18n }}</label>
<input
id="use-passkeys"
type="checkbox"
aria-describedby="use-passkeysHelp"
(change)="updateEnablePasskeys()"
[(ngModel)]="enablePasskeys"
/>
</div>
</div>
<div id="use-passkeysHelp" class="box-footer">
{{ "usePasskeysDesc" | i18n }}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="addlogin-notification-bar">{{ "enableAddLoginNotification" | i18n }}</label>
<input
id="addlogin-notification-bar"
type="checkbox"
aria-describedby="addlogin-notification-barHelp"
(change)="updateAddLoginNotification()"
[(ngModel)]="enableAddLoginNotification"
/>
</div>
</div>
<div id="addlogin-notification-barHelp" class="box-footer">
{{
accountSwitcherEnabled
? ("addLoginNotificationDescAlt" | i18n)
: ("addLoginNotificationDesc" | i18n)
}}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="changedpass-notification-bar">{{
"enableChangedPasswordNotification" | i18n
}}</label>
<input
id="changedpass-notification-bar"
type="checkbox"
aria-describedby="changedpass-notification-barHelp"
(change)="updateChangedPasswordNotification()"
[(ngModel)]="enableChangedPasswordNotification"
/>
</div>
</div>
<div id="changedpass-notification-barHelp" class="box-footer">
{{
accountSwitcherEnabled
? ("changedPasswordNotificationDescAlt" | i18n)
: ("changedPasswordNotificationDesc" | i18n)
}}
</div>
</div>
<div class="box list">
<div class="box-content single-line">
<button
type="button"
class="box-content-row box-content-row-flex text-default"
routerLink="/excluded-domains"
>
<div class="row-main">{{ "excludedDomains" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button>
</div>
</div>
</main>

View File

@@ -0,0 +1,53 @@
import { Component, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { enableAccountSwitching } from "../../../platform/flags";
@Component({
selector: "autofill-notification-settings",
templateUrl: "notifications.component.html",
})
export class NotifcationsSettingsComponent implements OnInit {
enableAddLoginNotification = false;
enableChangedPasswordNotification = false;
enablePasskeys = true;
accountSwitcherEnabled = false;
constructor(
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private vaultSettingsService: VaultSettingsService,
) {
this.accountSwitcherEnabled = enableAccountSwitching();
}
async ngOnInit() {
this.enableAddLoginNotification = await firstValueFrom(
this.userNotificationSettingsService.enableAddedLoginPrompt$,
);
this.enableChangedPasswordNotification = await firstValueFrom(
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);
this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
}
async updateAddLoginNotification() {
await this.userNotificationSettingsService.setEnableAddedLoginPrompt(
this.enableAddLoginNotification,
);
}
async updateChangedPasswordNotification() {
await this.userNotificationSettingsService.setEnableChangedPasswordPrompt(
this.enableChangedPasswordNotification,
);
}
async updateEnablePasskeys() {
await this.vaultSettingsService.setEnablePasskeys(this.enablePasskeys);
}
}

View File

@@ -1,12 +1,16 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../browser-popup-utils";
@Component({ @Component({
selector: "app-pop-out", selector: "app-pop-out",
templateUrl: "pop-out.component.html", templateUrl: "pop-out.component.html",
standalone: true,
imports: [CommonModule, JslibModule],
}) })
export class PopOutComponent implements OnInit { export class PopOutComponent implements OnInit {
@Input() show = true; @Input() show = true;
@@ -24,9 +28,7 @@ export class PopOutComponent implements OnInit {
} }
} }
expand() { async expand() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await BrowserPopupUtils.openCurrentPagePopout(window);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserPopupUtils.openCurrentPagePopout(window);
} }
} }

View File

@@ -163,6 +163,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
* the view is open. * the view is open.
*/ */
async isViewOpen(): Promise<boolean> { async isViewOpen(): Promise<boolean> {
if (this.isSafari()) {
// Query views on safari since chrome.runtime.sendMessage does not timeout and will hang.
return BrowserApi.isPopupOpen();
}
return Boolean(await BrowserApi.sendMessageWithResponse("checkVaultPopupHeartbeat")); return Boolean(await BrowserApi.sendMessageWithResponse("checkVaultPopupHeartbeat"));
} }

View File

@@ -196,9 +196,6 @@ export const routerTransition = trigger("routerTransition", [
transition("vault-settings => sync", inSlideLeft), transition("vault-settings => sync", inSlideLeft),
transition("sync => vault-settings", outSlideRight), transition("sync => vault-settings", outSlideRight),
transition("tabs => excluded-domains", inSlideLeft),
transition("excluded-domains => tabs", outSlideRight),
transition("tabs => options", inSlideLeft), transition("tabs => options", inSlideLeft),
transition("options => tabs", outSlideRight), transition("options => tabs", outSlideRight),
@@ -223,6 +220,13 @@ export const routerTransition = trigger("routerTransition", [
transition("tabs => edit-send, send-type => edit-send", inSlideUp), transition("tabs => edit-send, send-type => edit-send", inSlideUp),
transition("edit-send => tabs, edit-send => send-type", outSlideDown), transition("edit-send => tabs, edit-send => send-type", outSlideDown),
// Notification settings
transition("tabs => notifications", inSlideLeft),
transition("notifications => tabs", outSlideRight),
transition("notifications => excluded-domains", inSlideLeft),
transition("excluded-domains => notifications", outSlideRight),
transition("tabs => autofill", inSlideLeft), transition("tabs => autofill", inSlideLeft),
transition("autofill => tabs", outSlideRight), transition("autofill => tabs", outSlideRight),

View File

@@ -27,6 +27,8 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotifcationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component"; import { PremiumComponent } from "../billing/popup/settings/premium.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component";
@@ -56,7 +58,6 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c
import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils"; import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils";
import { debounceNavigationGuard } from "./services/debounce-navigation.service"; import { debounceNavigationGuard } from "./services/debounce-navigation.service";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { TabsV2Component } from "./tabs-v2.component"; import { TabsV2Component } from "./tabs-v2.component";
@@ -256,6 +257,12 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { state: "account-security" }, data: { state: "account-security" },
}, },
{
path: "notifications",
component: NotifcationsSettingsComponent,
canActivate: [AuthGuard],
data: { state: "notifications" },
},
{ {
path: "vault-settings", path: "vault-settings",
component: VaultSettingsComponent, component: VaultSettingsComponent,

View File

@@ -14,6 +14,7 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components"; import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components";
import { ExportScopeCalloutComponent } from "@bitwarden/vault-export-ui"; import { ExportScopeCalloutComponent } from "@bitwarden/vault-export-ui";
@@ -37,7 +38,10 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp
import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotifcationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component"; import { PremiumComponent } from "../billing/popup/settings/premium.component";
import { PopOutComponent } from "../platform/popup/components/pop-out.component";
import { HeaderComponent } from "../platform/popup/header.component"; import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
@@ -78,10 +82,8 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c
import { AppRoutingModule } from "./app-routing.module"; import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component"; import { AppComponent } from "./app.component";
import { PopOutComponent } from "./components/pop-out.component";
import { UserVerificationComponent } from "./components/user-verification.component"; import { UserVerificationComponent } from "./components/user-verification.component";
import { ServicesModule } from "./services/services.module"; import { ServicesModule } from "./services/services.module";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { TabsV2Component } from "./tabs-v2.component"; import { TabsV2Component } from "./tabs-v2.component";
@@ -116,10 +118,12 @@ import "../platform/popup/locales";
AccountComponent, AccountComponent,
ButtonModule, ButtonModule,
ExportScopeCalloutComponent, ExportScopeCalloutComponent,
PopOutComponent,
PopupPageComponent, PopupPageComponent,
PopupTabNavigationComponent, PopupTabNavigationComponent,
PopupFooterComponent, PopupFooterComponent,
PopupHeaderComponent, PopupHeaderComponent,
UserVerificationDialogComponent,
], ],
declarations: [ declarations: [
ActionButtonsComponent, ActionButtonsComponent,
@@ -149,11 +153,11 @@ import "../platform/popup/locales";
LoginViaAuthRequestComponent, LoginViaAuthRequestComponent,
LoginDecryptionOptionsComponent, LoginDecryptionOptionsComponent,
OptionsComponent, OptionsComponent,
NotifcationsSettingsComponent,
AppearanceComponent, AppearanceComponent,
GeneratorComponent, GeneratorComponent,
PasswordGeneratorHistoryComponent, PasswordGeneratorHistoryComponent,
PasswordHistoryComponent, PasswordHistoryComponent,
PopOutComponent,
PremiumComponent, PremiumComponent,
RegisterComponent, RegisterComponent,
SendAddEditComponent, SendAddEditComponent,

View File

@@ -87,6 +87,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components"; import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { UnauthGuardService } from "../../auth/popup/services"; import { UnauthGuardService } from "../../auth/popup/services";
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
@@ -117,6 +118,7 @@ import { ForegroundMemoryStorageService } from "../../platform/storage/foregroun
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service"; import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service";
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service";
import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service";
import { VaultFilterService } from "../../vault/services/vault-filter.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service";
@@ -600,6 +602,11 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE, provide: CLIENT_TYPE,
useValue: ClientType.Browser, useValue: ClientType.Browser,
}), }),
safeProvider({
provide: Fido2UserVerificationService,
useClass: Fido2UserVerificationService,
deps: [PasswordRepromptService, UserVerificationService, DialogService],
}),
]; ];
@NgModule({ @NgModule({

View File

@@ -60,67 +60,6 @@
</div> </div>
<div id="totpHelp" class="box-footer">{{ "disableAutoTotpCopyDesc" | i18n }}</div> <div id="totpHelp" class="box-footer">{{ "disableAutoTotpCopyDesc" | i18n }}</div>
</div> </div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="addlogin-notification-bar">{{ "enableAddLoginNotification" | i18n }}</label>
<input
id="addlogin-notification-bar"
type="checkbox"
aria-describedby="addlogin-notification-barHelp"
(change)="updateAddLoginNotification()"
[(ngModel)]="enableAddLoginNotification"
/>
</div>
</div>
<div id="addlogin-notification-barHelp" class="box-footer">
{{
accountSwitcherEnabled
? ("addLoginNotificationDescAlt" | i18n)
: ("addLoginNotificationDesc" | i18n)
}}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="changedpass-notification-bar">{{
"enableChangedPasswordNotification" | i18n
}}</label>
<input
id="changedpass-notification-bar"
type="checkbox"
aria-describedby="changedpass-notification-barHelp"
(change)="updateChangedPasswordNotification()"
[(ngModel)]="enableChangedPasswordNotification"
/>
</div>
</div>
<div id="changedpass-notification-barHelp" class="box-footer">
{{
accountSwitcherEnabled
? ("changedPasswordNotificationDescAlt" | i18n)
: ("changedPasswordNotificationDesc" | i18n)
}}
</div>
</div>
<div class="box">
<div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="use-passkeys">{{ "enableUsePasskeys" | i18n }}</label>
<input
id="use-passkeys"
type="checkbox"
aria-describedby="use-passkeysHelp"
(change)="updateEnablePasskeys()"
[(ngModel)]="enablePasskeys"
/>
</div>
</div>
<div id="use-passkeysHelp" class="box-footer">
{{ "usePasskeysDesc" | i18n }}
</div>
</div>
<div class="box"> <div class="box">
<div class="box-content"> <div class="box-content">
<div class="box-content-row box-content-row-checkbox" appBoxRow> <div class="box-content-row box-content-row-checkbox" appBoxRow>

View File

@@ -3,7 +3,6 @@ import { firstValueFrom } from "rxjs";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types"; import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types";
import { import {
UriMatchStrategy, UriMatchStrategy,
@@ -25,9 +24,6 @@ export class OptionsComponent implements OnInit {
autoFillOnPageLoadOptions: any[]; autoFillOnPageLoadOptions: any[];
enableAutoTotpCopy = false; // TODO: Does it matter if this is set to false or true? enableAutoTotpCopy = false; // TODO: Does it matter if this is set to false or true?
enableContextMenuItem = false; enableContextMenuItem = false;
enableAddLoginNotification = false;
enableChangedPasswordNotification = false;
enablePasskeys = true;
showCardsCurrentTab = false; showCardsCurrentTab = false;
showIdentitiesCurrentTab = false; showIdentitiesCurrentTab = false;
showClearClipboard = true; showClearClipboard = true;
@@ -36,13 +32,11 @@ export class OptionsComponent implements OnInit {
clearClipboard: ClearClipboardDelaySetting; clearClipboard: ClearClipboardDelaySetting;
clearClipboardOptions: any[]; clearClipboardOptions: any[];
showGeneral = true; showGeneral = true;
showAutofill = true;
showDisplay = true; showDisplay = true;
accountSwitcherEnabled = false; accountSwitcherEnabled = false;
constructor( constructor(
private messagingService: MessagingService, private messagingService: MessagingService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
i18nService: I18nService, i18nService: I18nService,
@@ -82,14 +76,6 @@ export class OptionsComponent implements OnInit {
this.autofillSettingsService.autofillOnPageLoadDefault$, this.autofillSettingsService.autofillOnPageLoadDefault$,
); );
this.enableAddLoginNotification = await firstValueFrom(
this.userNotificationSettingsService.enableAddedLoginPrompt$,
);
this.enableChangedPasswordNotification = await firstValueFrom(
this.userNotificationSettingsService.enableChangedPasswordPrompt$,
);
this.enableContextMenuItem = await firstValueFrom( this.enableContextMenuItem = await firstValueFrom(
this.autofillSettingsService.enableContextMenu$, this.autofillSettingsService.enableContextMenu$,
); );
@@ -101,8 +87,6 @@ export class OptionsComponent implements OnInit {
this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$);
this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$);
const defaultUriMatch = await firstValueFrom( const defaultUriMatch = await firstValueFrom(
this.domainSettingsService.defaultUriMatchStrategy$, this.domainSettingsService.defaultUriMatchStrategy$,
); );
@@ -111,22 +95,6 @@ export class OptionsComponent implements OnInit {
this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$);
} }
async updateAddLoginNotification() {
await this.userNotificationSettingsService.setEnableAddedLoginPrompt(
this.enableAddLoginNotification,
);
}
async updateChangedPasswordNotification() {
await this.userNotificationSettingsService.setEnableChangedPasswordPrompt(
this.enableChangedPasswordNotification,
);
}
async updateEnablePasskeys() {
await this.vaultSettingsService.setEnablePasskeys(this.enablePasskeys);
}
async updateContextMenuItem() { async updateContextMenuItem() {
await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem); await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem);
this.messagingService.send("bgUpdateContextMenu"); this.messagingService.send("bgUpdateContextMenu");

View File

@@ -9,11 +9,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { ButtonModule, DialogModule } from "@bitwarden/components"; import { ButtonModule, DialogModule } from "@bitwarden/components";
@Component({ @Component({
templateUrl: "about.component.html", templateUrl: "about-dialog.component.html",
standalone: true, standalone: true,
imports: [CommonModule, JslibModule, DialogModule, ButtonModule], imports: [CommonModule, JslibModule, DialogModule, ButtonModule],
}) })
export class AboutComponent { export class AboutDialogComponent {
protected year = new Date().getFullYear(); protected year = new Date().getFullYear();
protected version$: Observable<string>; protected version$: Observable<string>;

View File

@@ -30,17 +30,17 @@
<button <button
type="button" type="button"
class="box-content-row box-content-row-flex text-default" class="box-content-row box-content-row-flex text-default"
routerLink="/vault-settings" routerLink="/notifications"
> >
<div class="row-main">{{ "vault" | i18n }}</div> <div class="row-main">{{ "notifications" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i> <i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button> </button>
<button <button
type="button" type="button"
class="box-content-row box-content-row-flex text-default" class="box-content-row box-content-row-flex text-default"
routerLink="/excluded-domains" routerLink="/vault-settings"
> >
<div class="row-main">{{ "excludedDomains" | i18n }}</div> <div class="row-main">{{ "vault" | i18n }}</div>
<i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i> <i class="bwi bwi-angle-right bwi-lg row-sub-icon" aria-hidden="true"></i>
</button> </button>
</div> </div>

View File

@@ -13,7 +13,7 @@ import { DialogService } from "@bitwarden/components";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { AboutComponent } from "./about/about.component"; import { AboutDialogComponent } from "./about-dialog/about-dialog.component";
const RateUrls = { const RateUrls = {
[DeviceType.ChromeExtension]: [DeviceType.ChromeExtension]:
@@ -84,7 +84,7 @@ export class SettingsComponent implements OnInit {
} }
about() { about() {
this.dialogService.open(AboutComponent); this.dialogService.open(AboutDialogComponent);
} }
rate() { rate() {

View File

@@ -27,13 +27,13 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service"; import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service";
import { import {
BrowserFido2Message, BrowserFido2Message,
BrowserFido2UserInterfaceSession, BrowserFido2UserInterfaceSession,
} from "../../../fido2/browser-fido2-user-interface.service"; } from "../../../fido2/browser-fido2-user-interface.service";
import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service";
import { VaultPopoutType } from "../../utils/vault-popout-window"; import { VaultPopoutType } from "../../utils/vault-popout-window";
interface ViewData { interface ViewData {
@@ -59,6 +59,7 @@ export class Fido2Component implements OnInit, OnDestroy {
protected data$: Observable<ViewData>; protected data$: Observable<ViewData>;
protected sessionId?: string; protected sessionId?: string;
protected senderTabId?: string; protected senderTabId?: string;
protected fromLock?: boolean;
protected ciphers?: CipherView[] = []; protected ciphers?: CipherView[] = [];
protected displayedCiphers?: CipherView[] = []; protected displayedCiphers?: CipherView[] = [];
protected loading = false; protected loading = false;
@@ -71,13 +72,13 @@ export class Fido2Component implements OnInit, OnDestroy {
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private cipherService: CipherService, private cipherService: CipherService,
private passwordRepromptService: PasswordRepromptService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
private searchService: SearchService, private searchService: SearchService,
private logService: LogService, private logService: LogService,
private dialogService: DialogService, private dialogService: DialogService,
private browserMessagingApi: ZonedMessageListenerService, private browserMessagingApi: ZonedMessageListenerService,
private fido2UserVerificationService: Fido2UserVerificationService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -89,6 +90,7 @@ export class Fido2Component implements OnInit, OnDestroy {
sessionId: queryParamMap.get("sessionId"), sessionId: queryParamMap.get("sessionId"),
senderTabId: queryParamMap.get("senderTabId"), senderTabId: queryParamMap.get("senderTabId"),
senderUrl: queryParamMap.get("senderUrl"), senderUrl: queryParamMap.get("senderUrl"),
fromLock: queryParamMap.get("fromLock"),
})), })),
); );
@@ -101,6 +103,7 @@ export class Fido2Component implements OnInit, OnDestroy {
this.sessionId = queryParams.sessionId; this.sessionId = queryParams.sessionId;
this.senderTabId = queryParams.senderTabId; this.senderTabId = queryParams.senderTabId;
this.url = queryParams.senderUrl; this.url = queryParams.senderUrl;
this.fromLock = queryParams.fromLock === "true";
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session. // For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
if ( if (
message.type === "NewSessionCreatedRequest" && message.type === "NewSessionCreatedRequest" &&
@@ -210,7 +213,11 @@ export class Fido2Component implements OnInit, OnDestroy {
protected async submit() { protected async submit() {
const data = this.message$.value; const data = this.message$.value;
if (data?.type === "PickCredentialRequest") { if (data?.type === "PickCredentialRequest") {
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher); const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
this.send({ this.send({
sessionId: this.sessionId, sessionId: this.sessionId,
@@ -231,7 +238,11 @@ export class Fido2Component implements OnInit, OnDestroy {
} }
} }
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher); const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
this.send({ this.send({
sessionId: this.sessionId, sessionId: this.sessionId,
@@ -248,14 +259,21 @@ export class Fido2Component implements OnInit, OnDestroy {
const data = this.message$.value; const data = this.message$.value;
if (data?.type === "ConfirmNewCredentialRequest") { if (data?.type === "ConfirmNewCredentialRequest") {
const name = data.credentialName || data.rpId; const name = data.credentialName || data.rpId;
await this.createNewCipher(name); const userVerified = await this.fido2UserVerificationService.handleUserVerification(
data.userVerification,
this.cipher,
this.fromLock,
);
if (!data.userVerification || userVerified) {
await this.createNewCipher(name);
}
// We are bypassing user verification pending implementation of PIN and biometric support.
this.send({ this.send({
sessionId: this.sessionId, sessionId: this.sessionId,
cipherId: this.cipher?.id, cipherId: this.cipher?.id,
type: "ConfirmNewCredentialResponse", type: "ConfirmNewCredentialResponse",
userVerified: data.userVerification, userVerified,
}); });
} }
@@ -304,6 +322,7 @@ export class Fido2Component implements OnInit, OnDestroy {
uilocation: "popout", uilocation: "popout",
senderTabId: this.senderTabId, senderTabId: this.senderTabId,
sessionId: this.sessionId, sessionId: this.sessionId,
fromLock: this.fromLock,
userVerification: data.userVerification, userVerification: data.userVerification,
singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`, singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`,
}, },
@@ -374,20 +393,6 @@ export class Fido2Component implements OnInit, OnDestroy {
} }
} }
private async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
if (masterPasswordRepromptRequired) {
return await this.passwordRepromptService.showPasswordPrompt();
}
// We are bypassing user verification pending implementation of PIN and biometric support.
return userVerificationRequested;
}
private send(msg: BrowserFido2Message) { private send(msg: BrowserFido2Message) {
BrowserFido2UserInterfaceSession.sendMessage({ BrowserFido2UserInterfaceSession.sendMessage({
sessionId: this.sessionId, sessionId: this.sessionId,

View File

@@ -30,6 +30,7 @@ import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils";
import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service"; import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service";
import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service"; import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service";
import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service";
import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data"; import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data";
import { closeAddEditVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window"; import { closeAddEditVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window";
@@ -69,6 +70,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService: DialogService, dialogService: DialogService,
datePipe: DatePipe, datePipe: DatePipe,
configService: ConfigService, configService: ConfigService,
private fido2UserVerificationService: Fido2UserVerificationService,
) { ) {
super( super(
cipherService, cipherService,
@@ -168,11 +170,17 @@ export class AddEditComponent extends BaseAddEditComponent {
async submit(): Promise<boolean> { async submit(): Promise<boolean> {
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$); const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
const { isFido2Session, sessionId, userVerification } = fido2SessionData; const { isFido2Session, sessionId, userVerification, fromLock } = fido2SessionData;
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session; const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
if ( if (
inFido2PopoutWindow && inFido2PopoutWindow &&
!(await this.handleFido2UserVerification(sessionId, userVerification)) userVerification &&
!(await this.fido2UserVerificationService.handleUserVerification(
userVerification,
this.cipher,
fromLock,
))
) { ) {
return false; return false;
} }
@@ -327,14 +335,6 @@ export class AddEditComponent extends BaseAddEditComponent {
}, 200); }, 200);
} }
private async handleFido2UserVerification(
sessionId: string,
userVerification: boolean,
): Promise<boolean> {
// We are bypassing user verification pending implementation of PIN and biometric support.
return true;
}
repromptChanged() { repromptChanged() {
super.repromptChanged(); super.repromptChanged();

View File

@@ -16,6 +16,7 @@ export function fido2PopoutSessionData$() {
fallbackSupported: queryParams.fallbackSupported === "true", fallbackSupported: queryParams.fallbackSupported === "true",
userVerification: queryParams.userVerification === "true", userVerification: queryParams.userVerification === "true",
senderUrl: queryParams.senderUrl as string, senderUrl: queryParams.senderUrl as string,
fromLock: queryParams.fromLock === "true",
})), })),
); );
} }

View File

@@ -0,0 +1,248 @@
import { MockProxy, mock } from "jest-mock-extended";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { SetPinComponent } from "./../../auth/popup/components/set-pin.component";
import { Fido2UserVerificationService } from "./fido2-user-verification.service";
jest.mock("@bitwarden/auth/angular", () => ({
UserVerificationDialogComponent: {
open: jest.fn().mockResolvedValue({ userAction: "confirm", verificationSuccess: true }),
},
}));
jest.mock("../../auth/popup/components/set-pin.component", () => {
return {
SetPinComponent: {
open: jest.fn(),
},
};
});
describe("Fido2UserVerificationService", () => {
let fido2UserVerificationService: Fido2UserVerificationService;
let passwordRepromptService: MockProxy<PasswordRepromptService>;
let userVerificationService: MockProxy<UserVerificationService>;
let dialogService: MockProxy<DialogService>;
let cipher: CipherView;
beforeEach(() => {
passwordRepromptService = mock<PasswordRepromptService>();
userVerificationService = mock<UserVerificationService>();
dialogService = mock<DialogService>();
cipher = createCipherView();
fido2UserVerificationService = new Fido2UserVerificationService(
passwordRepromptService,
userVerificationService,
dialogService,
);
(UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({
userAction: "confirm",
verificationSuccess: true,
});
});
describe("handleUserVerification", () => {
describe("user verification requested is true", () => {
it("should return true if user is redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled();
expect(result).toBe(true);
});
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
true,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen and no master password reprompt is required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
true,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should prompt user to set pin if user has no verification method", async () => {
(UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({
userAction: "confirm",
verificationSuccess: false,
noAvailableClientVerificationMethods: true,
});
await fido2UserVerificationService.handleUserVerification(true, cipher, false);
expect(SetPinComponent.open).toHaveBeenCalledWith(dialogService);
});
});
describe("user verification requested is false", () => {
it("should return false if user is redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(result).toBe(false);
});
it("should return false if user is not redirected from lock screen and master password reprompt is not required", async () => {
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(result).toBe(false);
});
it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(true);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(result).toBe(true);
expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled();
});
it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
true,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => {
cipher.reprompt = CipherRepromptType.Password;
userVerificationService.hasMasterPassword.mockResolvedValue(false);
passwordRepromptService.showPasswordPrompt.mockResolvedValue(true);
const result = await fido2UserVerificationService.handleUserVerification(
false,
cipher,
false,
);
expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, {
clientSideOnlyVerification: true,
});
expect(result).toBe(true);
});
});
});
});
function createCipherView() {
const cipher = new CipherView();
cipher.id = Utils.newGuid();
cipher.type = CipherType.Login;
cipher.reprompt = CipherRepromptType.None;
return cipher;
}

View File

@@ -0,0 +1,101 @@
import { firstValueFrom } from "rxjs";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { SetPinComponent } from "../../auth/popup/components/set-pin.component";
export class Fido2UserVerificationService {
constructor(
private passwordRepromptService: PasswordRepromptService,
private userVerificationService: UserVerificationService,
private dialogService: DialogService,
) {}
/**
* Handles user verification for a user based on the cipher and user verification requested.
* @param userVerificationRequested Indicates if user verification is required or not.
* @param cipher Contains details about the cipher including master password reprompt.
* @param fromLock Indicates if the request is from the lock screen.
* @returns
*/
async handleUserVerification(
userVerificationRequested: boolean,
cipher: CipherView,
fromLock: boolean,
): Promise<boolean> {
const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0;
// If the request is from the lock screen, treat unlocking the vault as user verification,
// unless a master password reprompt is required.
if (fromLock) {
return masterPasswordRepromptRequired
? await this.handleMasterPasswordReprompt()
: userVerificationRequested;
}
if (masterPasswordRepromptRequired) {
return await this.handleMasterPasswordReprompt();
}
if (userVerificationRequested) {
return await this.showUserVerificationDialog();
}
return userVerificationRequested;
}
private async showMasterPasswordReprompt(): Promise<boolean> {
return await this.passwordRepromptService.showPasswordPrompt();
}
private async showUserVerificationDialog(): Promise<boolean> {
const result = await UserVerificationDialogComponent.open(this.dialogService, {
clientSideOnlyVerification: true,
});
if (result.userAction === "cancel") {
return;
}
// Handle unsuccessful verification attempts.
if (!result.verificationSuccess) {
// Check if no client-side verification methods are available.
if (result.noAvailableClientVerificationMethods) {
return await this.promptUserToSetPin();
}
return;
}
return result.verificationSuccess;
}
private async handleMasterPasswordReprompt(): Promise<boolean> {
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
// TDE users have no master password, so we need to use the UserVerification prompt
return hasMasterPassword
? await this.showMasterPasswordReprompt()
: await this.showUserVerificationDialog();
}
private async promptUserToSetPin() {
const dialogRef = SetPinComponent.open(this.dialogService);
if (!dialogRef) {
return;
}
const userHasPinSet = await firstValueFrom(dialogRef.closed);
if (!userHasPinSet) {
return;
}
// If the user has set a PIN, re-invoke the user verification dialog to complete the verification process.
return await this.showUserVerificationDialog();
}
}

View File

@@ -165,23 +165,4 @@ export class OrganizationUserResetPasswordService {
} }
return requests; return requests;
} }
/**
* @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data.
*/
async postLegacyRotation(
userId: string,
requests: OrganizationUserResetPasswordWithIdRequest[],
): Promise<void> {
if (requests == null) {
return;
}
for (const request of requests) {
await this.organizationUserService.putOrganizationUserResetPasswordEnrollment(
request.organizationId,
userId,
request,
);
}
}
} }

View File

@@ -1,6 +1,6 @@
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom } from "rxjs"; import { concatMap, takeUntil, map } from "rxjs";
import { tap } from "rxjs/operators"; import { tap } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -16,7 +16,6 @@ import { DialogService } from "@bitwarden/components";
import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component"; import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component";
import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component"; import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component";
import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor-verify.component";
@Component({ @Component({
selector: "app-two-factor-setup", selector: "app-two-factor-setup",
@@ -66,17 +65,17 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent {
async manage(type: TwoFactorProviderType) { async manage(type: TwoFactorProviderType) {
switch (type) { switch (type) {
case TwoFactorProviderType.OrganizationDuo: { case TwoFactorProviderType.OrganizationDuo: {
const twoFactorVerifyDialogRef = TwoFactorVerifyComponent.open(this.dialogService, { const result: AuthResponse<TwoFactorDuoResponse> = await this.callTwoFactorVerifyDialog(
data: { type: type, organizationId: this.organizationId }, TwoFactorProviderType.OrganizationDuo,
});
const result: AuthResponse<TwoFactorDuoResponse> = await lastValueFrom(
twoFactorVerifyDialogRef.closed,
); );
if (!result) { if (!result) {
return; return;
} }
const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent); const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent);
duoComp.type = TwoFactorProviderType.OrganizationDuo;
duoComp.organizationId = this.organizationId;
duoComp.auth(result); duoComp.auth(result);
duoComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => { duoComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => {
this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo); this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo);

View File

@@ -2,7 +2,17 @@ import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router"; import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery"; import * as jq from "jquery";
import { Subject, filter, firstValueFrom, map, switchMap, takeUntil, timeout, timer } from "rxjs"; import {
Subject,
combineLatest,
filter,
firstValueFrom,
map,
switchMap,
takeUntil,
timeout,
timer,
} from "rxjs";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -15,6 +25,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
@@ -241,8 +252,12 @@ export class AppComponent implements OnDestroy, OnInit {
new SendOptionsPolicy(), new SendOptionsPolicy(),
]); ]);
this.paymentMethodWarningsRefresh$ combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.ShowPaymentMethodWarningBanners),
this.paymentMethodWarningsRefresh$,
])
.pipe( .pipe(
filter(([showPaymentMethodWarningBanners]) => showPaymentMethodWarningBanners),
switchMap(() => this.organizationService.memberOrganizations$), switchMap(() => this.organizationService.memberOrganizations$),
switchMap( switchMap(
async (organizations) => async (organizations) =>

View File

@@ -328,16 +328,4 @@ export class EmergencyAccessService {
private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise<EncryptedString> { private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise<EncryptedString> {
return (await this.cryptoService.rsaEncrypt(userKey.key, publicKey)).encryptedString; return (await this.cryptoService.rsaEncrypt(userKey.key, publicKey)).encryptedString;
} }
/**
* @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data.
*/
async postLegacyRotation(requests: EmergencyAccessWithIdRequest[]): Promise<void> {
if (requests == null) {
return;
}
for (const request of requests) {
await this.emergencyAccessApiService.putEmergencyAccess(request.id, request);
}
}
} }

View File

@@ -82,7 +82,6 @@ describe("KeyRotationService", () => {
mockEncryptService, mockEncryptService,
mockStateService, mockStateService,
mockAccountService, mockAccountService,
mockConfigService,
mockKdfConfigService, mockKdfConfigService,
); );
}); });
@@ -191,16 +190,6 @@ describe("KeyRotationService", () => {
); );
}); });
it("uses legacy rotation if feature flag is off", async () => {
mockConfigService.getFeatureFlag.mockResolvedValueOnce(false);
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword");
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
expect(mockEmergencyAccessService.postLegacyRotation).toHaveBeenCalled();
expect(mockResetPasswordService.postLegacyRotation).toHaveBeenCalled();
});
it("throws if server rotation fails", async () => { it("throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));

View File

@@ -5,8 +5,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
@@ -39,7 +37,6 @@ export class UserKeyRotationService {
private encryptService: EncryptService, private encryptService: EncryptService,
private stateService: StateService, private stateService: StateService,
private accountService: AccountService, private accountService: AccountService,
private configService: ConfigService,
private kdfConfigService: KdfConfigService, private kdfConfigService: KdfConfigService,
) {} ) {}
@@ -90,11 +87,7 @@ export class UserKeyRotationService {
request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey); request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey);
request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey); request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey);
if (await this.configService.getFeatureFlag(FeatureFlag.KeyRotationImprovements)) { await this.apiService.postUserKeyUpdate(request);
await this.apiService.postUserKeyUpdate(request);
} else {
await this.rotateUserKeyAndEncryptedDataLegacy(request);
}
const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.rotateDevicesTrust( await this.deviceTrustService.rotateDevicesTrust(
@@ -139,16 +132,4 @@ export class UserKeyRotationService {
}), }),
); );
} }
private async rotateUserKeyAndEncryptedDataLegacy(request: UpdateKeyRequest): Promise<void> {
// Update keys, ciphers, folders, and sends
await this.apiService.postUserKeyUpdate(request);
// Update emergency access keys
await this.emergencyAccessService.postLegacyRotation(request.emergencyAccessKeys);
// Update account recovery keys
const userId = await this.stateService.getUserId();
await this.resetPasswordService.postLegacyRotation(userId, request.resetPasswordKeys);
}
} }

View File

@@ -1,129 +1,143 @@
<div *ngIf="selfHosted" class="page-header"> <bit-section>
<h1>{{ "subscription" | i18n }}</h1> <h2 *ngIf="!selfHosted" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
</div> <bit-callout
<div *ngIf="!selfHosted" class="tabbed-header"> type="info"
<h1>{{ "goPremium" | i18n }}</h1> *ngIf="canAccessPremium$ | async"
</div> title="{{ 'youHavePremiumAccess' | i18n }}"
<bit-callout icon="bwi bwi-star-f"
type="info"
*ngIf="canAccessPremium$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p class="text-lg" [ngClass]="{ 'mb-0': !selfHosted }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a routerLink="/create-organization" [queryParams]="{ plan: 'families' }">{{
"bitwardenFamiliesPlan" | i18n
}}</a>
</p>
<a
bitButton
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription/premium"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="selfHosted"
> >
{{ "purchasePremium" | i18n }} {{ "alreadyPremiumFromOrg" | i18n }}
</a> </bit-callout>
</bit-callout> <bit-callout type="success">
<ng-container *ngIf="selfHosted"> <p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<p>{{ "uploadLicenseFilePremium" | i18n }}</p> <ul class="bwi-ul">
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <li>
<div class="form-group"> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
<label for="file">{{ "licenseFile" | i18n }}</label> {{ "premiumSignUpStorage" | i18n }}
<input type="file" id="file" class="form-control-file" name="file" required /> </li>
<small class="form-text text-muted">{{ <li>
"licenseFileDesc" | i18n: "bitwarden_premium_license.json" <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
}}</small> {{ "premiumSignUpTwoStepOptions" | i18n }}
</div> </li>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading"> <li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !selfHosted }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>{{ "bitwardenFamiliesPlan" | i18n }}</a
>
</p>
<a
bitButton
href="{{ this.cloudWebVaultUrl }}/#/settings/subscription/premium"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="selfHosted"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="selfHosted">
<p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<form [formGroup]="licenseForm" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "licenseFile" | i18n }}</bit-label>
<div>
<button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
{{ "chooseFile" | i18n }}
</button>
{{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }}
</div>
<input
bitInput
#fileSelector
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
hidden
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }} {{ "submit" | i18n }}
</button> </button>
</form> </form>
</ng-container> </bit-section>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="!selfHosted"> <form [formGroup]="addonForm" [bitSubmit]="submit" *ngIf="!selfHosted">
<h2 class="mt-5">{{ "addons" | i18n }}</h2> <bit-section>
<div class="row"> <h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="form-group col-6"> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<label for="additionalStorage">{{ "additionalStorageGb" | i18n }}</label> <bit-form-field class="tw-col-span-6">
<input <bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
id="additionalStorage" <input
class="form-control" bitInput
type="number" formControlName="additionalStorage"
name="AdditionalStorageGb" type="number"
[(ngModel)]="additionalStorage" step="1"
min="0" placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
max="99" />
step="1" <bit-hint>{{
placeholder="{{ 'additionalStorageGbDesc' | i18n }}" "additionalStorageIntervalDesc"
/> | i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
<small class="text-muted form-text">{{ }}</bit-hint>
"additionalStorageIntervalDesc" </bit-form-field>
| i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
}}</small>
</div> </div>
</div> </bit-section>
<h2 class="spaced-header">{{ "summary" | i18n }}</h2> <bit-section>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br /> <h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times; {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ storageGbPrice | currency: "$" }} = {{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times;
{{ additionalStorageTotal | currency: "$" }} {{ storageGbPrice | currency: "$" }} =
<hr class="my-3" /> {{ additionalStorageTotal | currency: "$" }}
<h2 class="spaced-header mb-4">{{ "paymentInformation" | i18n }}</h2> <hr class="tw-my-3" />
<app-payment [hideBank]="true"></app-payment> </bit-section>
<app-tax-info></app-tax-info> <bit-section>
<div id="price" class="my-4"> <h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="text-muted text-sm"> <app-payment [hideBank]="true"></app-payment>
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} <app-tax-info></app-tax-info>
<br /> <div id="price" class="tw-my-4">
<ng-container> <div class="tw-text-muted tw-text-sm">
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }} {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}
</ng-container> <br />
<ng-container>
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }}
</ng-container>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
</div> </div>
<hr class="my-1 col-3 ml-0" /> <p bitTypography="body2">{{ "paymentChargedAnnually" | i18n }}</p>
<p class="text-lg"> <button type="submit" bitButton bitFormButton>
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }} {{ "submit" | i18n }}
</p> </button>
</div> </bit-section>
<small class="text-muted font-italic">{{ "paymentChargedAnnually" | i18n }}</small>
<button type="submit" bitButton [loading]="form.loading">
{{ "submit" | i18n }}
</button>
</form> </form>

View File

@@ -1,4 +1,5 @@
import { Component, OnInit, ViewChild } from "@angular/core"; import { Component, OnInit, ViewChild } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Observable } from "rxjs"; import { firstValueFrom, Observable } from "rxjs";
@@ -7,7 +8,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@@ -26,11 +26,16 @@ export class PremiumComponent implements OnInit {
premiumPrice = 10; premiumPrice = 10;
familyPlanMaxUserCount = 6; familyPlanMaxUserCount = 6;
storageGbPrice = 4; storageGbPrice = 4;
additionalStorage = 0;
cloudWebVaultUrl: string; cloudWebVaultUrl: string;
licenseFile: File = null;
formPromise: Promise<any>; formPromise: Promise<any>;
protected licenseForm = new FormGroup({
file: new FormControl(null, [Validators.required]),
});
protected addonForm = new FormGroup({
additionalStorage: new FormControl(0, [Validators.max(99), Validators.min(0)]),
});
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private i18nService: I18nService, private i18nService: I18nService,
@@ -39,14 +44,17 @@ export class PremiumComponent implements OnInit {
private router: Router, private router: Router,
private messagingService: MessagingService, private messagingService: MessagingService,
private syncService: SyncService, private syncService: SyncService,
private logService: LogService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private billingAccountProfileStateService: BillingAccountProfileStateService, private billingAccountProfileStateService: BillingAccountProfileStateService,
) { ) {
this.selfHosted = platformUtilsService.isSelfHost(); this.selfHosted = platformUtilsService.isSelfHost();
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
} }
protected setSelectedFile(event: Event) {
const fileInputEl = <HTMLInputElement>event.target;
const file: File = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
this.licenseFile = file;
}
async ngOnInit() { async ngOnInit() {
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) {
@@ -56,13 +64,11 @@ export class PremiumComponent implements OnInit {
return; return;
} }
} }
submit = async () => {
async submit() { this.licenseForm.markAllAsTouched();
let files: FileList = null; this.addonForm.markAllAsTouched();
if (this.selfHosted) { if (this.selfHosted) {
const fileEl = document.getElementById("file") as HTMLInputElement; if (this.licenseFile == null) {
files = fileEl.files;
if (files == null || files.length === 0) {
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"error", "error",
this.i18nService.t("errorOccurred"), this.i18nService.t("errorOccurred"),
@@ -72,53 +78,48 @@ export class PremiumComponent implements OnInit {
} }
} }
try { if (this.selfHosted) {
if (this.selfHosted) { // eslint-disable-next-line @typescript-eslint/no-misused-promises
// eslint-disable-next-line @typescript-eslint/no-misused-promises if (!this.tokenService.getEmailVerified()) {
if (!this.tokenService.getEmailVerified()) { this.platformUtilsService.showToast(
this.platformUtilsService.showToast( "error",
"error", this.i18nService.t("errorOccurred"),
this.i18nService.t("errorOccurred"), this.i18nService.t("verifyEmailFirst"),
this.i18nService.t("verifyEmailFirst"), );
); return;
return;
}
const fd = new FormData();
fd.append("license", files[0]);
this.formPromise = this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium();
});
} else {
this.formPromise = this.paymentComponent
.createPaymentToken()
.then((result) => {
const fd = new FormData();
fd.append("paymentMethodType", result[1].toString());
if (result[0] != null) {
fd.append("paymentToken", result[0]);
}
fd.append("additionalStorageGb", (this.additionalStorage || 0).toString());
fd.append("country", this.taxInfoComponent.taxInfo.country);
fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode);
return this.apiService.postPremium(fd);
})
.then((paymentResponse) => {
if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) {
return this.paymentComponent.handleStripeCardPayment(
paymentResponse.paymentIntentClientSecret,
() => this.finalizePremium(),
);
} else {
return this.finalizePremium();
}
});
} }
await this.formPromise;
} catch (e) { const fd = new FormData();
this.logService.error(e); fd.append("license", this.licenseFile);
await this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium();
});
} else {
await this.paymentComponent
.createPaymentToken()
.then((result) => {
const fd = new FormData();
fd.append("paymentMethodType", result[1].toString());
if (result[0] != null) {
fd.append("paymentToken", result[0]);
}
fd.append("additionalStorageGb", (this.additionalStorage || 0).toString());
fd.append("country", this.taxInfoComponent.taxInfo.country);
fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode);
return this.apiService.postPremium(fd);
})
.then((paymentResponse) => {
if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) {
return this.paymentComponent.handleStripeCardPayment(
paymentResponse.paymentIntentClientSecret,
() => this.finalizePremium(),
);
} else {
return this.finalizePremium();
}
});
} }
} };
async finalizePremium() { async finalizePremium() {
await this.apiService.refreshIdentityToken(); await this.apiService.refreshIdentityToken();
@@ -127,6 +128,9 @@ export class PremiumComponent implements OnInit {
await this.router.navigate(["/settings/subscription/user-subscription"]); await this.router.navigate(["/settings/subscription/user-subscription"]);
} }
get additionalStorage(): number {
return this.addonForm.get("additionalStorage").value;
}
get additionalStorageTotal(): number { get additionalStorageTotal(): number {
return this.storageGbPrice * Math.abs(this.additionalStorage || 0); return this.storageGbPrice * Math.abs(this.additionalStorage || 0);
} }

View File

@@ -1,65 +1,57 @@
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <form [formGroup]="adjustSubscriptionForm" [bitSubmit]="submit">
<div> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="row"> <div class="tw-col-span-8">
<div class="form-group col-8"> <bit-form-field>
<label for="newSeatCount">{{ "subscriptionSeats" | i18n }}</label> <bit-label>{{ "subscriptionSeats" | i18n }}</bit-label>
<input <input bitInput formControlName="newSeatCount" type="number" min="0" step="1" />
id="newSeatCount" <bit-hint>
class="form-control"
type="number"
name="NewSeatCount"
[(ngModel)]="newSeatCount"
min="0"
step="1"
required
/>
<small class="d-block text-muted mb-4">
<strong>{{ "total" | i18n }}:</strong> {{ additionalSeatCount || 0 }} &times; <strong>{{ "total" | i18n }}:</strong> {{ additionalSeatCount || 0 }} &times;
{{ seatPrice | currency: "$" }} = {{ adjustedSeatTotal | currency: "$" }} / {{ seatPrice | currency: "$" }} = {{ adjustedSeatTotal | currency: "$" }} /
{{ interval | i18n }} {{ interval | i18n }}</bit-hint
</small> >
</div> </bit-form-field>
</div> </div>
<div class="row mb-4"> </div>
<div class="form-group col-sm"> <div>
<div class="form-check"> <bit-form-control>
<input <input
id="limitSubscription" bitCheckbox
class="form-check-input" formControlName="limitSubscription"
type="checkbox" type="checkbox"
name="LimitSubscription" (change)="limitSubscriptionChanged()"
[(ngModel)]="limitSubscription" />
(change)="limitSubscriptionChanged()" <bit-label>{{ "limitSubscription" | i18n }}</bit-label>
/> <bit-hint> {{ "limitSubscriptionDesc" | i18n }}</bit-hint>
<label for="limitSubscription">{{ "limitSubscription" | i18n }}</label> </bit-form-control>
</div> </div>
<small class="d-block text-muted">{{ "limitSubscriptionDesc" | i18n }}</small> <div
</div> class="tw-grid tw-grid-cols-12 tw-gap-4 tw-mb-4"
</div> [hidden]="!adjustSubscriptionForm.value.limitSubscription"
<div class="row mb-4" [hidden]="!limitSubscription"> >
<div class="form-group col-sm"> <div class="tw-col-span-8">
<label for="maxAutoscaleSeats">{{ "maxSeatLimit" | i18n }}</label> <bit-form-field>
<bit-label>{{ "maxSeatLimit" | i18n }}</bit-label>
<input <input
id="maxAutoscaleSeats" bitInput
class="form-control col-8" formControlName="newMaxSeats"
type="number" type="number"
name="MaxAutoscaleSeats" [min]="
[(ngModel)]="newMaxSeats" adjustSubscriptionForm.value.newSeatCount == null
[min]="newSeatCount == null ? 1 : newSeatCount" ? 1
: adjustSubscriptionForm.value.newSeatCount
"
step="1" step="1"
[required]="limitSubscription"
/> />
<small class="d-block text-muted"> <bit-hint>
<strong>{{ "maxSeatCost" | i18n }}:</strong> {{ additionalMaxSeatCount || 0 }} &times; <strong>{{ "maxSeatCost" | i18n }}:</strong> {{ additionalMaxSeatCount || 0 }} &times;
{{ seatPrice | currency: "$" }} = {{ maxSeatTotal | currency: "$" }} / {{ seatPrice | currency: "$" }} = {{ maxSeatTotal | currency: "$" }} /
{{ interval | i18n }} {{ interval | i18n }}</bit-hint
</small> >
</div> </bit-form-field>
</div> </div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
</div> </div>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
</form> </form>
<app-payment [showMethods]="false"></app-payment> <app-payment [showMethods]="false"></app-payment>

View File

@@ -1,77 +1,102 @@
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@Component({ @Component({
selector: "app-adjust-subscription", selector: "app-adjust-subscription",
templateUrl: "adjust-subscription.component.html", templateUrl: "adjust-subscription.component.html",
}) })
export class AdjustSubscription { export class AdjustSubscription implements OnInit, OnDestroy {
@Input() organizationId: string; @Input() organizationId: string;
@Input() maxAutoscaleSeats: number; @Input() maxAutoscaleSeats: number;
@Input() currentSeatCount: number; @Input() currentSeatCount: number;
@Input() seatPrice = 0; @Input() seatPrice = 0;
@Input() interval = "year"; @Input() interval = "year";
@Output() onAdjusted = new EventEmitter(); @Output() onAdjusted = new EventEmitter();
private destroy$ = new Subject<void>();
formPromise: Promise<void>; adjustSubscriptionForm = this.formBuilder.group({
limitSubscription: boolean; newSeatCount: [0, [Validators.min(0)]],
newSeatCount: number; limitSubscription: [false],
newMaxSeats: number; newMaxSeats: [0, [Validators.min(0)]],
});
get limitSubscription(): boolean {
return this.adjustSubscriptionForm.value.limitSubscription;
}
constructor( constructor(
private i18nService: I18nService, private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction, private organizationApiService: OrganizationApiServiceAbstraction,
private formBuilder: FormBuilder,
) {} ) {}
ngOnInit() { ngOnInit() {
this.limitSubscription = this.maxAutoscaleSeats != null; this.adjustSubscriptionForm.patchValue({
this.newSeatCount = this.currentSeatCount; newSeatCount: this.currentSeatCount,
this.newMaxSeats = this.maxAutoscaleSeats; limitSubscription: this.maxAutoscaleSeats != null,
newMaxSeats: this.maxAutoscaleSeats,
});
this.adjustSubscriptionForm
.get("limitSubscription")
.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((value: boolean) => {
if (value) {
this.adjustSubscriptionForm
.get("newMaxSeats")
.addValidators([
Validators.min(
this.adjustSubscriptionForm.value.newSeatCount == null
? 1
: this.adjustSubscriptionForm.value.newSeatCount,
),
Validators.required,
]);
}
this.adjustSubscriptionForm.get("newMaxSeats").updateValueAndValidity();
});
} }
async submit() { ngOnDestroy() {
try { this.destroy$.next();
const request = new OrganizationSubscriptionUpdateRequest( this.destroy$.complete();
this.additionalSeatCount, }
this.newMaxSeats, submit = async () => {
); this.adjustSubscriptionForm.markAllAsTouched();
this.formPromise = this.organizationApiService.updatePasswordManagerSeats( if (this.adjustSubscriptionForm.invalid) {
this.organizationId, return;
request,
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("subscriptionUpdated"),
);
} catch (e) {
this.logService.error(e);
} }
const request = new OrganizationSubscriptionUpdateRequest(
this.additionalSeatCount,
this.adjustSubscriptionForm.value.newMaxSeats,
);
await this.organizationApiService.updatePasswordManagerSeats(this.organizationId, request);
this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
this.onAdjusted.emit(); this.onAdjusted.emit();
} };
limitSubscriptionChanged() { limitSubscriptionChanged() {
if (!this.limitSubscription) { if (!this.adjustSubscriptionForm.value.limitSubscription) {
this.newMaxSeats = null; this.adjustSubscriptionForm.value.newMaxSeats = null;
} }
} }
get additionalSeatCount(): number { get additionalSeatCount(): number {
return this.newSeatCount ? this.newSeatCount - this.currentSeatCount : 0; return this.adjustSubscriptionForm.value.newSeatCount
? this.adjustSubscriptionForm.value.newSeatCount - this.currentSeatCount
: 0;
} }
get additionalMaxSeatCount(): number { get additionalMaxSeatCount(): number {
return this.newMaxSeats ? this.newMaxSeats - this.currentSeatCount : 0; return this.adjustSubscriptionForm.value.newMaxSeats
? this.adjustSubscriptionForm.value.newMaxSeats - this.currentSeatCount
: 0;
} }
get adjustedSeatTotal(): number { get adjustedSeatTotal(): number {

View File

@@ -1,400 +1,428 @@
<ng-container *ngIf="loading"> <ng-container *ngIf="loading">
<i <i
class="bwi bwi-spinner bwi-spin text-muted" class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}" title="{{ 'loading' | i18n }}"
aria-hidden="true" aria-hidden="true"
></i> ></i>
<span class="sr-only">{{ "loading" | i18n }}</span> <span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container> </ng-container>
<ng-container *ngIf="createOrganization && selfHosted"> <ng-container *ngIf="createOrganization && selfHosted">
<p>{{ "uploadLicenseFileOrg" | i18n }}</p> <p bitTypography="body1">{{ "uploadLicenseFileOrg" | i18n }}</p>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <form [formGroup]="selfHostedForm" [bitSubmit]="submit">
<div class="form-group"> <bit-form-field>
<label for="file">{{ "licenseFile" | i18n }}</label> <bit-label>{{ "licenseFile" | i18n }}</bit-label>
<input type="file" id="file" class="form-control-file" name="file" required /> <div>
<small class="form-text text-muted">{{ <button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
"licenseFileDesc" | i18n: "bitwarden_organization_license.json" {{ "chooseFile" | i18n }}
}}</small> </button>
</div> {{ selectedFile?.name ?? ("noFileChosen" | i18n) }}
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> </div>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> <input
<span>{{ "submit" | i18n }}</span> #fileSelector
hidden
bitInput
type="file"
formControlName="file"
(change)="setSelectedFile($event)"
accept="application/JSON"
/>
<bit-hint>{{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }}</bit-hint>
</bit-form-field>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "submit" | i18n }}
</button> </button>
</form> </form>
</ng-container> </ng-container>
<form <form
#form
[formGroup]="formGroup" [formGroup]="formGroup"
(ngSubmit)="submit()" [bitSubmit]="submit"
[appApiAction]="formPromise"
ngNativeValidate
*ngIf="!loading && !selfHosted && this.passwordManagerPlans && this.secretsManagerPlans" *ngIf="!loading && !selfHosted && this.passwordManagerPlans && this.secretsManagerPlans"
class="tw-pt-6" class="tw-pt-6"
> >
<app-org-info <bit-section>
(changedBusinessOwned)="changedOwnedBusiness()" <app-org-info
[formGroup]="formGroup" (changedBusinessOwned)="changedOwnedBusiness()"
[createOrganization]="createOrganization" [formGroup]="formGroup"
[isProvider]="!!providerId" [createOrganization]="createOrganization"
[acceptingSponsorship]="acceptingSponsorship" [isProvider]="!!providerId"
></app-org-info> [acceptingSponsorship]="acceptingSponsorship"
<h2 class="mt-5">{{ "chooseYourPlan" | i18n }}</h2> >
<div *ngFor="let selectableProduct of selectableProducts" class="form-check form-check-block"> </app-org-info>
<input </bit-section>
class="form-check-input" <bit-section>
type="radio" <h2 bitTypography="h2">{{ "chooseYourPlan" | i18n }}</h2>
name="product" <div *ngFor="let selectableProduct of selectableProducts">
id="product{{ selectableProduct.product }}" <bit-radio-group formControlName="product" [block]="true">
[value]="selectableProduct.product" <bit-radio-button [value]="selectableProduct.product" (change)="changedProduct()">
formControlName="product" <bit-label>{{ selectableProduct.nameLocalizationKey | i18n }}</bit-label>
(change)="changedProduct()" <bit-hint class="tw-text-sm"
/> >{{ selectableProduct.descriptionLocalizationKey | i18n: "1" }}
<label class="form-check-label" for="product{{ selectableProduct.product }}"> <ng-container
{{ selectableProduct.nameLocalizationKey | i18n }} *ngIf="selectableProduct.product === productTypes.Enterprise; else nonEnterprisePlans"
<small class="mb-1">{{ selectableProduct.descriptionLocalizationKey | i18n: "1" }}</small> >
<ng-container <ul class="tw-pl-0 tw-list-inside tw-mb-0">
*ngIf="selectableProduct.product === productTypes.Enterprise; else nonEnterprisePlans" <li>{{ "includeAllTeamsFeatures" | i18n }}</li>
> <li *ngIf="selectableProduct.hasSelfHost">{{ "onPremHostingOptional" | i18n }}</li>
<small>{{ "includeAllTeamsFeatures" | i18n }}</small> <li *ngIf="selectableProduct.hasSso">{{ "includeSsoAuthentication" | i18n }}</li>
<small *ngIf="selectableProduct.hasSelfHost">• {{ "onPremHostingOptional" | i18n }}</small> <li *ngIf="selectableProduct.hasPolicies">
<small *ngIf="selectableProduct.hasSso">• {{ "includeSsoAuthentication" | i18n }}</small> {{ "includeEnterprisePolicies" | i18n }}
<small *ngIf="selectableProduct.hasPolicies" </li>
>• {{ "includeEnterprisePolicies" | i18n }}</small <li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
> {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization" </li>
> </ul>
{{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }} </ng-container>
</small> <ng-template #nonEnterprisePlans>
</ng-container> <ng-container
<ng-template #nonEnterprisePlans> *ngIf="selectableProduct.product === productTypes.Teams; else fullFeatureList"
<ng-container >
*ngIf="selectableProduct.product === productTypes.Teams; else fullFeatureList" <ul class="tw-pl-0 tw-list-inside tw-mb-0">
> <li>{{ "includeAllTeamsStarterFeatures" | i18n }}</li>
<small>{{ "includeAllTeamsStarterFeatures" | i18n }}</small> <li>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</li>
<small>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</small> <li>{{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</li>
<small>• {{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</small> <li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization"> {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
• {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }} </li>
</small> </ul>
</ng-container> </ng-container>
<ng-template #fullFeatureList> <ng-template #fullFeatureList>
<small *ngIf="selectableProduct.product == productTypes.Free" <ul class="tw-pl-0 tw-list-inside tw-mb-0">
>• {{ "limitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}</small <li *ngIf="selectableProduct.product == productTypes.Free">
{{ "limitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}
</li>
<li
*ngIf="
selectableProduct.product != productTypes.Free &&
selectableProduct.product != productTypes.TeamsStarter &&
selectableProduct.PasswordManager.maxSeats
"
>
{{ "addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}
</li>
<li *ngIf="!selectableProduct.PasswordManager.maxSeats">
{{ "addShareUnlimitedUsers" | i18n }}
</li>
<li *ngIf="selectableProduct.PasswordManager.maxCollections">
{{
"limitedCollections" | i18n: selectableProduct.PasswordManager.maxCollections
}}
</li>
<li *ngIf="selectableProduct.PasswordManager.maxAdditionalSeats">
{{
"addShareLimitedUsers"
| i18n: selectableProduct.PasswordManager.maxAdditionalSeats
}}
</li>
<li *ngIf="!selectableProduct.PasswordManager.maxCollections">
{{ "createUnlimitedCollections" | i18n }}
</li>
<li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
{{
"gbEncryptedFileStorage"
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
}}
</li>
<li *ngIf="selectableProduct.hasGroups">
{{ "controlAccessWithGroups" | i18n }}
</li>
<li *ngIf="selectableProduct.hasApi">{{ "trackAuditLogs" | i18n }}</li>
<li *ngIf="selectableProduct.hasDirectory">
{{ "syncUsersFromDirectory" | i18n }}
</li>
<li *ngIf="selectableProduct.hasSelfHost">
{{ "onPremHostingOptional" | i18n }}
</li>
<li *ngIf="selectableProduct.usersGetPremium">{{ "usersGetPremium" | i18n }}</li>
<li *ngIf="selectableProduct.product != productTypes.Free">
{{ "priorityCustomerSupport" | i18n }}
</li>
<li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
{{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
</li>
</ul>
</ng-template>
</ng-template>
</bit-hint>
</bit-radio-button>
<span *ngIf="selectableProduct.product != productTypes.Free">
<ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
> >
<small
*ngIf="
selectableProduct.product != productTypes.Free &&
selectableProduct.product != productTypes.TeamsStarter &&
selectableProduct.PasswordManager.maxSeats
"
>
{{ "addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}</small
>
<small *ngIf="!selectableProduct.PasswordManager.maxSeats"
>• {{ "addShareUnlimitedUsers" | i18n }}</small
>
<small *ngIf="selectableProduct.PasswordManager.maxCollections"
>
{{
"limitedCollections" | i18n: selectableProduct.PasswordManager.maxCollections
}}</small
>
<small *ngIf="selectableProduct.PasswordManager.maxAdditionalSeats"
>
{{
"addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxAdditionalSeats
}}</small
>
<small *ngIf="!selectableProduct.PasswordManager.maxCollections"
>• {{ "createUnlimitedCollections" | i18n }}</small
>
<small *ngIf="selectableProduct.PasswordManager.baseStorageGb"
>
{{
"gbEncryptedFileStorage"
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
}}</small
>
<small *ngIf="selectableProduct.hasGroups"
>• {{ "controlAccessWithGroups" | i18n }}</small
>
<small *ngIf="selectableProduct.hasApi">• {{ "trackAuditLogs" | i18n }}</small>
<small *ngIf="selectableProduct.hasDirectory"
>• {{ "syncUsersFromDirectory" | i18n }}</small
>
<small *ngIf="selectableProduct.hasSelfHost"
>• {{ "onPremHostingOptional" | i18n }}</small
>
<small *ngIf="selectableProduct.usersGetPremium">• {{ "usersGetPremium" | i18n }}</small>
<small *ngIf="selectableProduct.product != productTypes.Free"
>• {{ "priorityCustomerSupport" | i18n }}</small
>
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization"
>
{{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
</small>
</ng-template>
</ng-template>
<span *ngIf="selectableProduct.product != productTypes.Free">
<ng-container *ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship">
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
: selectableProduct.PasswordManager.basePrice
) | currency: "$"
}}
/{{ "month" | i18n }},
{{ "includesXUsers" | i18n: selectableProduct.PasswordManager.baseSeats }}
<ng-container *ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption">
{{ ("additionalUsers" | i18n).toLowerCase() }}
{{ {{
(selectableProduct.isAnnual (selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12 ? selectableProduct.PasswordManager.basePrice / 12
: selectableProduct.PasswordManager.seatPrice : selectableProduct.PasswordManager.basePrice
) | currency: "$" ) | currency: "$"
}} }}
/{{ "month" | i18n }} /{{ "month" | i18n }},
</ng-container> {{ "includesXUsers" | i18n: selectableProduct.PasswordManager.baseSeats }}
</ng-container> <ng-container *ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption">
</span> {{ ("additionalUsers" | i18n).toLowerCase() }}
<span {{
*ngIf=" (selectableProduct.isAnnual
!selectableProduct.PasswordManager.basePrice &&
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
{{
"costPerUser"
| i18n
: ((selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12 ? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice : selectableProduct.PasswordManager.seatPrice
) ) | currency: "$"
| currency: "$") }}
}} /{{ "month" | i18n }}
/{{ "month" | i18n }} </ng-container>
</span> </ng-container>
<span *ngIf="selectableProduct.product == productTypes.Free">{{ "freeForever" | i18n }}</span> </span>
</label> <span
</div> *ngIf="
<div *ngIf="formGroup.value.product !== productTypes.Free"> !selectableProduct.PasswordManager.basePrice &&
<ng-container selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
{{
"costPerUser"
| i18n
: ((selectableProduct.isAnnual
? selectableProduct.PasswordManager.seatPrice / 12
: selectableProduct.PasswordManager.seatPrice
)
| currency: "$")
}}
/{{ "month" | i18n }}
</span>
<span *ngIf="selectableProduct.product == productTypes.Free">{{
"freeForever" | i18n
}}</span>
</bit-radio-group>
</div>
</bit-section>
<bit-section *ngIf="formGroup.value.product !== productTypes.Free">
<bit-section
*ngIf=" *ngIf="
selectedPlan.PasswordManager.hasAdditionalSeatsOption && selectedPlan.PasswordManager.hasAdditionalSeatsOption &&
!selectedPlan.PasswordManager.baseSeats !selectedPlan.PasswordManager.baseSeats
" "
> >
<h2 class="mt-5">{{ "users" | i18n }}</h2> <h2 bitTypography="h2">{{ "users" | i18n }}</h2>
<div class="row"> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="col-6"> <bit-form-field class="tw-col-span-6">
<label for="additionalSeats">{{ "userSeats" | i18n }}</label> <bit-label>{{ "userSeats" | i18n }}</bit-label>
<input <input
id="additionalSeats" bitInput
class="form-control"
type="number" type="number"
name="additionalSeats"
formControlName="additionalSeats" formControlName="additionalSeats"
placeholder="{{ 'userSeatsDesc' | i18n }}" placeholder="{{ 'userSeatsDesc' | i18n }}"
required required
/> />
<small class="text-muted form-text">{{ "userSeatsHowManyDesc" | i18n }}</small> <bit-hint class="tw-text-sm">{{ "userSeatsHowManyDesc" | i18n }}</bit-hint>
</div> </bit-form-field>
</div> </div>
</ng-container> </bit-section>
<h2 class="mt-5">{{ "addons" | i18n }}</h2> <bit-section>
<div <h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
class="row" <div
*ngIf=" class="tw-grid tw-grid-cols-12 tw-gap-4"
selectedPlan.PasswordManager.hasAdditionalSeatsOption && *ngIf="
selectedPlan.PasswordManager.baseSeats selectedPlan.PasswordManager.hasAdditionalSeatsOption &&
" selectedPlan.PasswordManager.baseSeats
> "
<div class="form-group col-6"> >
<label for="additionalSeats">{{ "additionalUserSeats" | i18n }}</label> <bit-form-field class="tw-col-span-6">
<input <bit-label>{{ "additionalUserSeats" | i18n }}</bit-label>
id="additionalSeats"
class="form-control"
type="number"
name="additionalSeats"
formControlName="additionalSeats"
placeholder="{{ 'userSeatsDesc' | i18n }}"
/>
<small class="text-muted form-text">{{
"userSeatsAdditionalDesc"
| i18n
: selectedPlan.PasswordManager.baseSeats
: (seatPriceMonthly(selectedPlan) | currency: "$")
}}</small>
</div>
</div>
<div class="row">
<div class="form-group col-6">
<label for="additionalStorage">{{ "additionalStorageGb" | i18n }}</label>
<input
id="additionalStorage"
class="form-control"
type="number"
name="additionalStorageGb"
formControlName="additionalStorage"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<small class="text-muted form-text">{{
"additionalStorageIntervalDesc"
| i18n
: "1 GB"
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
: ("month" | i18n)
}}</small>
</div>
</div>
<div class="row">
<div class="form-group col-6" *ngIf="selectedPlan.PasswordManager.hasPremiumAccessOption">
<div class="form-check">
<input <input
id="premiumAccess" bitInput
class="form-check-input" type="number"
type="checkbox" formControlName="additionalSeats"
name="premiumAccessAddon" placeholder="{{ 'userSeatsDesc' | i18n }}"
formControlName="premiumAccessAddon"
/> />
<label for="premiumAccess" class="form-check-label bold">{{ <bit-hint class="tx-text-sm"
"premiumAccess" | i18n >{{
}}</label> "userSeatsAdditionalDesc"
</div> | i18n
<small class="text-muted form-text">{{ : selectedPlan.PasswordManager.baseSeats
"premiumAccessDesc" | i18n: (3.33 | currency: "$") : ("month" | i18n) : (seatPriceMonthly(selectedPlan) | currency: "$")
}}</small> }}
</bit-hint>
</bit-form-field>
</div> </div>
</div> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<h2 class="spaced-header">{{ "summary" | i18n }}</h2> <bit-form-field class="tw-col-span-6">
<div class="form-check form-check-block" *ngFor="let selectablePlan of selectablePlans"> <bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input <input
class="form-check-input" bitInput
type="radio" type="number"
name="plan" formControlName="additionalStorage"
id="interval{{ selectablePlan.type }}" step="1"
[value]="selectablePlan.type" placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
formControlName="plan" />
/> <bit-hint class="tw-text-sm">{{
<label class="form-check-label" for="interval{{ selectablePlan.type }}"> "additionalStorageIntervalDesc"
<ng-container *ngIf="selectablePlan.isAnnual"> | i18n
{{ "annually" | i18n }} : "1 GB"
<small *ngIf="selectablePlan.PasswordManager.basePrice"> : (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
{{ "basePrice" | i18n }}: : ("month" | i18n)
{{ }}</bit-hint>
(selectablePlan.isAnnual </bit-form-field>
? selectablePlan.PasswordManager.basePrice / 12 </div>
: selectablePlan.PasswordManager.basePrice </bit-section>
) | currency: "$" <bit-section>
}} <div
&times; 12 class="tw-grid tw-grid-cols-12 tw-gap-4"
{{ "monthAbbr" | i18n }} *ngIf="selectedPlan.PasswordManager.hasPremiumAccessOption"
= >
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship"> <bit-form-control class="tw-col-span-6">
<span style="text-decoration: line-through">{{ <bit-label>{{ "premiumAccess" | i18n }}</bit-label>
selectablePlan.PasswordManager.basePrice | currency: "$" <input type="checkbox" bitCheckbox formControlName="premiumAccessAddon" />
}}</span> <bit-hint class="tw-text-sm">{{
{{ "freeWithSponsorship" | i18n }} "premiumAccessDesc" | i18n: (3.33 | currency: "$") : ("month" | i18n)
</ng-container> }}</bit-hint>
<ng-template #notAcceptingSponsorship> </bit-form-control>
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }} </div>
</bit-section>
<bit-section *ngFor="let selectablePlan of selectablePlans">
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
<bit-radio-group formControlName="plan">
<bit-radio-button
type="radio"
id="interval{{ selectablePlan.type }}"
[value]="selectablePlan.type"
>
<bit-label>{{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }}</bit-label>
<bit-hint *ngIf="selectablePlan.isAnnual">
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}:
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.basePrice / 12
: selectablePlan.PasswordManager.basePrice
) | currency: "$"
}}
&times; 12
{{ "monthAbbr" | i18n }}
=
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship">
<span class="tw-line-through">{{
selectablePlan.PasswordManager.basePrice | currency: "$"
}}</span>
{{ "freeWithSponsorship" | i18n }}
</ng-container>
<ng-template #notAcceptingSponsorship>
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "year" | i18n }}
</ng-template>
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
>
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
{{ formGroup.controls["additionalSeats"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.seatPrice / 12
: selectablePlan.PasswordManager.seatPrice
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "year" | i18n }} /{{ "year" | i18n }}
</ng-template> </p>
</small> <p
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"> class="tw-mb-0"
<span *ngIf="selectablePlan.PasswordManager.baseSeats" bitTypography="body2"
>{{ "additionalUsers" | i18n }}:</span *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
> >
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span> {{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalSeats"].value || 0 }} &times; {{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ {{
(selectablePlan.isAnnual (selectablePlan.isAnnual
? selectablePlan.PasswordManager.seatPrice / 12 ? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12
: selectablePlan.PasswordManager.seatPrice : selectablePlan.PasswordManager.additionalStoragePricePerGb
) | currency: "$" ) | currency: "$"
}} }}
&times; 12 {{ "monthAbbr" | i18n }} = &times; 12 {{ "monthAbbr" | i18n }} =
{{ {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }}
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) </p>
| currency: "$" </bit-hint>
}} <bit-hint *ngIf="!selectablePlan.isAnnual">
/{{ "year" | i18n }} <p
</small> class="tw-mb-0"
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"> bitTypography="body2"
{{ "additionalStorageGb" | i18n }}: *ngIf="selectablePlan.PasswordManager.basePrice"
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{
(selectablePlan.isAnnual
? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12
: selectablePlan.PasswordManager.additionalStoragePricePerGb
) | currency: "$"
}}
&times; 12 {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }}
</small>
</ng-container>
<ng-container *ngIf="!selectablePlan.isAnnual">
{{ "monthly" | i18n }}
<small *ngIf="selectablePlan.PasswordManager.basePrice">
{{ "basePrice" | i18n }}:
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }}
=
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "month" | i18n }}
</small>
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption">
<span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
> >
<span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span> {{ "basePrice" | i18n }}:
{{ formGroup.controls["additionalSeats"].value || 0 }} &times; {{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
{{ selectablePlan.PasswordManager.seatPrice | currency: "$" }} {{ "monthAbbr" | i18n }}
{{ "monthAbbr" | i18n }} = =
{{ {{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) /{{ "month" | i18n }}
| currency: "$" </p>
}} <p
/{{ "month" | i18n }} class="tw-mb-0"
</small> bitTypography="body2"
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"> *ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
{{ "additionalStorageGb" | i18n }}: >
{{ formGroup.controls["additionalStorage"].value || 0 }} &times; <span *ngIf="selectablePlan.PasswordManager.baseSeats"
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} >{{ "additionalUsers" | i18n }}:</span
{{ "monthAbbr" | i18n }} = >
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }} <span *ngIf="!selectablePlan.PasswordManager.baseSeats">{{ "users" | i18n }}:</span>
</small> {{ formGroup.controls["additionalSeats"].value || 0 }} &times;
</ng-container> {{ selectablePlan.PasswordManager.seatPrice | currency: "$" }}
</label> {{ "monthAbbr" | i18n }} =
</div> {{
</div> passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats)
| currency: "$"
}}
/{{ "month" | i18n }}
</p>
<p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }}
</p>
</bit-hint>
</bit-radio-button>
</bit-radio-group>
</bit-section>
</bit-section>
<!-- Secrets Manager --> <!-- Secrets Manager -->
<div class="tw-my-10"> <bit-section>
<sm-subscribe <sm-subscribe
*ngIf="planOffersSecretsManager && !hasProvider" *ngIf="planOffersSecretsManager && !hasProvider"
[formGroup]="formGroup.controls.secretsManager" [formGroup]="formGroup.controls.secretsManager"
[selectedPlan]="selectedSecretsManagerPlan" [selectedPlan]="selectedSecretsManagerPlan"
[upgradeOrganization]="!createOrganization" [upgradeOrganization]="!createOrganization"
></sm-subscribe> ></sm-subscribe>
</div> </bit-section>
<!-- Payment info --> <!-- Payment info -->
<div *ngIf="formGroup.value.product !== productTypes.Free"> <bit-section *ngIf="formGroup.value.product !== productTypes.Free">
<h2 class="mb-4"> <h2 bitTypography="h2">
{{ (createOrganization ? "paymentInformation" : "billingInformation") | i18n }} {{ (createOrganization ? "paymentInformation" : "billingInformation") | i18n }}
</h2> </h2>
<small class="text-muted font-italic mb-3 d-block"> <p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }} {{ paymentDesc }}
</small> </p>
<app-payment <app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod" *ngIf="createOrganization || upgradeRequiresPaymentMethod"
[hideCredit]="true" [hideCredit]="true"
></app-payment> ></app-payment>
<app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info> <app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info>
<div id="price" class="my-4"> <div id="price" class="tw-my-4">
<div class="text-muted text-sm"> <div class="tw-text-muted tw-text-base">
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }} {{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }}
<br /> <br />
<span *ngIf="planOffersSecretsManager && formGroup.value.secretsManager.enabled"> <span *ngIf="planOffersSecretsManager && formGroup.value.secretsManager.enabled">
@@ -405,8 +433,8 @@
{{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }} {{ "estimatedTax" | i18n }}: {{ taxCharges | currency: "USD $" }}
</ng-container> </ng-container>
</div> </div>
<hr class="my-1 col-3 ml-0" /> <hr class="tw-my-1 tw-grid tw-grid-cols-3 tw-ml-0" />
<p class="text-lg"> <p class="tw-text-lg">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ <strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{
selectedPlanInterval | i18n selectedPlanInterval | i18n
}} }}
@@ -415,22 +443,29 @@
<ng-container *ngIf="!createOrganization"> <ng-container *ngIf="!createOrganization">
<app-payment [showMethods]="false"></app-payment> <app-payment [showMethods]="false"></app-payment>
</ng-container> </ng-container>
</div> </bit-section>
<div *ngIf="singleOrgPolicyBlock" class="mt-4"> <bit-section *ngIf="singleOrgPolicyBlock">
<app-callout [type]="'error'">{{ "singleOrgBlockCreateMessage" | i18n }}</app-callout> <app-callout [type]="'error'">{{ "singleOrgBlockCreateMessage" | i18n }}</app-callout>
</div> </bit-section>
<div class="mt-4"> <bit-section>
<button <button
type="submit" type="submit"
buttonType="primary" buttonType="primary"
bitButton bitButton
[loading]="form.loading" bitFormButton
[disabled]="!formGroup.valid" [disabled]="!formGroup.valid"
> >
{{ "submit" | i18n }} {{ "submit" | i18n }}
</button> </button>
<button type="button" buttonType="secondary" bitButton (click)="cancel()" *ngIf="showCancel"> <button
type="button"
buttonType="secondary"
bitButton
bitFormButton
(click)="cancel()"
*ngIf="showCancel"
>
{{ "cancel" | i18n }} {{ "cancel" | i18n }}
</button> </button>
</div> </bit-section>
</form> </form>

View File

@@ -70,6 +70,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
@Input() showCancel = false; @Input() showCancel = false;
@Input() acceptingSponsorship = false; @Input() acceptingSponsorship = false;
@Input() currentPlan: PlanResponse; @Input() currentPlan: PlanResponse;
selectedFile: File;
@Input() @Input()
get product(): ProductType { get product(): ProductType {
@@ -109,6 +110,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder); secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder);
selfHostedForm = this.formBuilder.group({
file: [null, [Validators.required]],
});
formGroup = this.formBuilder.group({ formGroup = this.formBuilder.group({
name: [""], name: [""],
billingEmail: ["", [Validators.email]], billingEmail: ["", [Validators.email]],
@@ -527,72 +532,72 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.onCanceled.emit(); this.onCanceled.emit();
} }
async submit() { setSelectedFile(event: Event) {
const fileInputEl = <HTMLInputElement>event.target;
this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null;
}
submit = async () => {
if (this.singleOrgPolicyBlock) { if (this.singleOrgPolicyBlock) {
return; return;
} }
const doSubmit = async (): Promise<string> => {
let orgId: string = null;
if (this.createOrganization) {
const orgKey = await this.cryptoService.makeOrgKey<OrgKey>();
const key = orgKey[0].encryptedString;
const collection = await this.cryptoService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey[1],
);
const collectionCt = collection.encryptedString;
const orgKeys = await this.cryptoService.makeKeyPair(orgKey[1]);
try { if (this.selfHosted) {
const doSubmit = async (): Promise<string> => { orgId = await this.createSelfHosted(key, collectionCt, orgKeys);
let orgId: string = null;
if (this.createOrganization) {
const orgKey = await this.cryptoService.makeOrgKey<OrgKey>();
const key = orgKey[0].encryptedString;
const collection = await this.cryptoService.encrypt(
this.i18nService.t("defaultCollection"),
orgKey[1],
);
const collectionCt = collection.encryptedString;
const orgKeys = await this.cryptoService.makeKeyPair(orgKey[1]);
if (this.selfHosted) {
orgId = await this.createSelfHosted(key, collectionCt, orgKeys);
} else {
orgId = await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1]);
}
this.platformUtilsService.showToast(
"success",
this.i18nService.t("organizationCreated"),
this.i18nService.t("organizationReadyToGo"),
);
} else { } else {
orgId = await this.updateOrganization(orgId); orgId = await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1]);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("organizationUpgraded"),
);
} }
await this.apiService.refreshIdentityToken(); this.platformUtilsService.showToast(
await this.syncService.fullSync(true); "success",
this.i18nService.t("organizationCreated"),
this.i18nService.t("organizationReadyToGo"),
);
} else {
orgId = await this.updateOrganization(orgId);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("organizationUpgraded"),
);
}
if (!this.acceptingSponsorship && !this.isInTrialFlow) { await this.apiService.refreshIdentityToken();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.syncService.fullSync(true);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/organizations/" + orgId]);
}
if (this.isInTrialFlow) { if (!this.acceptingSponsorship && !this.isInTrialFlow) {
this.onTrialBillingSuccess.emit({ // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
orgId: orgId, // eslint-disable-next-line @typescript-eslint/no-floating-promises
subLabelText: this.billingSubLabelText(), this.router.navigate(["/organizations/" + orgId]);
}); }
}
return orgId; if (this.isInTrialFlow) {
}; this.onTrialBillingSuccess.emit({
orgId: orgId,
subLabelText: this.billingSubLabelText(),
});
}
this.formPromise = doSubmit(); return orgId;
const organizationId = await this.formPromise; };
this.onSuccess.emit({ organizationId: organizationId });
// TODO: No one actually listening to this message? this.formPromise = doSubmit();
this.messagingService.send("organizationCreated", { organizationId }); const organizationId = await this.formPromise;
} catch (e) { this.onSuccess.emit({ organizationId: organizationId });
this.logService.error(e); // TODO: No one actually listening to this message?
} this.messagingService.send("organizationCreated", { organizationId });
} };
private async updateOrganization(orgId: string) { private async updateOrganization(orgId: string) {
const request = new OrganizationUpgradeRequest(); const request = new OrganizationUpgradeRequest();
@@ -693,14 +698,12 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
} }
private async createSelfHosted(key: string, collectionCt: string, orgKeys: [string, EncString]) { private async createSelfHosted(key: string, collectionCt: string, orgKeys: [string, EncString]) {
const fileEl = document.getElementById("file") as HTMLInputElement; if (!this.selectedFile) {
const files = fileEl.files;
if (files == null || files.length === 0) {
throw new Error(this.i18nService.t("selectFile")); throw new Error(this.i18nService.t("selectFile"));
} }
const fd = new FormData(); const fd = new FormData();
fd.append("license", files[0]); fd.append("license", this.selectedFile);
fd.append("key", key); fd.append("key", key);
fd.append("collectionName", collectionCt); fd.append("collectionName", collectionCt);
const response = await this.organizationApiService.createLicense(fd); const response = await this.organizationApiService.createLicense(fd);

View File

@@ -58,7 +58,7 @@ export class VaultCipherRowComponent {
} }
protected editCollections() { protected editCollections() {
this.onEvent.emit({ type: "viewCollections", item: this.cipher }); this.onEvent.emit({ type: "viewCipherCollections", item: this.cipher });
} }
protected events() { protected events() {

View File

@@ -63,7 +63,7 @@
</td> </td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button <button
*ngIf="canEditCollection || canDeleteCollection" *ngIf="canEditCollection || canDeleteCollection || canViewCollectionInfo"
[disabled]="disabled" [disabled]="disabled"
[bitMenuTriggerFor]="collectionOptions" [bitMenuTriggerFor]="collectionOptions"
size="small" size="small"
@@ -73,14 +73,28 @@
appStopProp appStopProp
></button> ></button>
<bit-menu #collectionOptions> <bit-menu #collectionOptions>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="edit()"> <ng-container *ngIf="canEditCollection">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> <button type="button" bitMenuItem (click)="edit(false)">
{{ "editInfo" | i18n }} <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
</button> {{ "editInfo" | i18n }}
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="access()"> </button>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> <button type="button" bitMenuItem (click)="access(false)">
{{ "access" | i18n }} <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
</button> {{ "access" | i18n }}
</button>
</ng-container>
<ng-container
*ngIf="flexibleCollectionsV1Enabled && !canEditCollection && canViewCollectionInfo"
>
<button type="button" bitMenuItem (click)="edit(true)">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button type="button" bitMenuItem (click)="access(true)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button *ngIf="canDeleteCollection" type="button" bitMenuItem (click)="deleteCollection()"> <button *ngIf="canDeleteCollection" type="button" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger"> <span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>

View File

@@ -30,9 +30,11 @@ export class VaultCollectionRowComponent {
@Input() showGroups: boolean; @Input() showGroups: boolean;
@Input() canEditCollection: boolean; @Input() canEditCollection: boolean;
@Input() canDeleteCollection: boolean; @Input() canDeleteCollection: boolean;
@Input() canViewCollectionInfo: boolean;
@Input() organizations: Organization[]; @Input() organizations: Organization[];
@Input() groups: GroupView[]; @Input() groups: GroupView[];
@Input() showPermissionsColumn: boolean; @Input() showPermissionsColumn: boolean;
@Input() flexibleCollectionsV1Enabled: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>(); @Output() onEvent = new EventEmitter<VaultItemEvent>();
@@ -71,12 +73,12 @@ export class VaultCollectionRowComponent {
return ""; return "";
} }
protected edit() { protected edit(readonly: boolean) {
this.onEvent.next({ type: "editCollection", item: this.collection }); this.onEvent.next({ type: "editCollection", item: this.collection, readonly: readonly });
} }
protected access() { protected access(readonly: boolean) {
this.onEvent.next({ type: "viewCollectionAccess", item: this.collection }); this.onEvent.next({ type: "viewCollectionAccess", item: this.collection, readonly: readonly });
} }
protected deleteCollection() { protected deleteCollection() {

View File

@@ -5,11 +5,11 @@ import { VaultItem } from "./vault-item";
export type VaultItemEvent = export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView } | { type: "viewAttachments"; item: CipherView }
| { type: "viewCollections"; item: CipherView } | { type: "viewCipherCollections"; item: CipherView }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewCollectionAccess"; item: CollectionView } | { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean }
| { type: "viewEvents"; item: CipherView } | { type: "viewEvents"; item: CipherView }
| { type: "editCollection"; item: CollectionView } | { type: "editCollection"; item: CollectionView; readonly: boolean }
| { type: "clone"; item: CipherView } | { type: "clone"; item: CipherView }
| { type: "restore"; items: CipherView[] } | { type: "restore"; items: CipherView[] }
| { type: "delete"; items: VaultItem[] } | { type: "delete"; items: VaultItem[] }

View File

@@ -95,13 +95,15 @@
[groups]="allGroups" [groups]="allGroups"
[canDeleteCollection]="canDeleteCollection(item.collection)" [canDeleteCollection]="canDeleteCollection(item.collection)"
[canEditCollection]="canEditCollection(item.collection)" [canEditCollection]="canEditCollection(item.collection)"
[canViewCollectionInfo]="canViewCollectionInfo(item.collection)"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled"
[checked]="selection.isSelected(item)" [checked]="selection.isSelected(item)"
(checkedToggled)="selection.toggle(item)" (checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)" (onEvent)="event($event)"
></tr> ></tr>
<!-- <!--
addAccessStatus check here so ciphers do not show if user addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess has filtered for collections with addAccess
--> -->
<tr <tr
*ngIf="item.cipher && (!addAccessToggle || (addAccessToggle && addAccessStatus !== 1))" *ngIf="item.cipher && (!addAccessToggle || (addAccessToggle && addAccessStatus !== 1))"

View File

@@ -165,6 +165,11 @@ export class VaultItemsComponent {
return collection.canDelete(organization); return collection.canDelete(organization);
} }
protected canViewCollectionInfo(collection: CollectionView) {
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
return collection.canViewCollectionInfo(organization);
}
protected toggleAll() { protected toggleAll() {
this.isAllSelected this.isAllSelected
? this.selection.clear() ? this.selection.clear()

View File

@@ -4,6 +4,7 @@ import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/mod
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import { CollectionAccessSelectionView } from "../../../admin-console/organizations/core/views/collection-access-selection.view"; import { CollectionAccessSelectionView } from "../../../admin-console/organizations/core/views/collection-access-selection.view";
import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
export class CollectionAdminView extends CollectionView { export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = []; groups: CollectionAccessSelectionView[] = [];
@@ -89,4 +90,19 @@ export class CollectionAdminView extends CollectionView {
canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups; return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups;
} }
/**
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection) {
return false;
}
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection;
}
get isUnassignedCollection() {
return this.id === Unassigned;
}
} }

View File

@@ -434,7 +434,7 @@ export class VaultComponent implements OnInit, OnDestroy {
try { try {
if (event.type === "viewAttachments") { if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item); await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") { } else if (event.type === "viewCipherCollections") {
await this.editCipherCollections(event.item); await this.editCipherCollections(event.item);
} else if (event.type === "clone") { } else if (event.type === "clone") {
await this.cloneCipher(event.item); await this.cloneCipher(event.item);

View File

@@ -37,24 +37,44 @@
aria-haspopup="true" aria-haspopup="true"
></button> ></button>
<bit-menu #editCollectionMenu> <bit-menu #editCollectionMenu>
<button <ng-container *ngIf="canEditCollection">
type="button" <button
*ngIf="canEditCollection" type="button"
bitMenuItem bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info)" (click)="editCollection(CollectionDialogTabType.Info, false)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container
*ngIf="flexibleCollectionsV1Enabled && !canEditCollection && canViewCollectionInfo"
> >
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> <button
{{ "editInfo" | i18n }} type="button"
</button> bitMenuItem
<button (click)="editCollection(CollectionDialogTabType.Info, true)"
type="button" >
*ngIf="canEditCollection" <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
bitMenuItem {{ "viewInfo" | i18n }}
(click)="editCollection(CollectionDialogTabType.Access)" </button>
> <button
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> type="button"
{{ "access" | i18n }} bitMenuItem
</button> (click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()"> <button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger"> <span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>

View File

@@ -53,7 +53,10 @@ export class VaultHeaderComponent implements OnInit {
@Output() onAddCollection = new EventEmitter<void>(); @Output() onAddCollection = new EventEmitter<void>();
/** Emits an event when the edit collection button is clicked in the header */ /** Emits an event when the edit collection button is clicked in the header */
@Output() onEditCollection = new EventEmitter<{ tab: CollectionDialogTabType }>(); @Output() onEditCollection = new EventEmitter<{
tab: CollectionDialogTabType;
readonly: boolean;
}>();
/** Emits an event when the delete collection button is clicked in the header */ /** Emits an event when the delete collection button is clicked in the header */
@Output() onDeleteCollection = new EventEmitter<void>(); @Output() onDeleteCollection = new EventEmitter<void>();
@@ -64,7 +67,7 @@ export class VaultHeaderComponent implements OnInit {
protected CollectionDialogTabType = CollectionDialogTabType; protected CollectionDialogTabType = CollectionDialogTabType;
protected organizations$ = this.organizationService.organizations$; protected organizations$ = this.organizationService.organizations$;
private flexibleCollectionsV1Enabled = false; protected flexibleCollectionsV1Enabled = false;
private restrictProviderAccessFlag = false; private restrictProviderAccessFlag = false;
constructor( constructor(
@@ -193,8 +196,8 @@ export class VaultHeaderComponent implements OnInit {
this.onAddCollection.emit(); this.onAddCollection.emit();
} }
async editCollection(tab: CollectionDialogTabType): Promise<void> { async editCollection(tab: CollectionDialogTabType, readonly: boolean): Promise<void> {
this.onEditCollection.emit({ tab }); this.onEditCollection.emit({ tab, readonly });
} }
get canDeleteCollection(): boolean { get canDeleteCollection(): boolean {
@@ -207,6 +210,10 @@ export class VaultHeaderComponent implements OnInit {
return this.collection.node.canDelete(this.organization); return this.collection.node.canDelete(this.organization);
} }
get canViewCollectionInfo(): boolean {
return this.collection.node.canViewCollectionInfo(this.organization);
}
get canCreateCollection(): boolean { get canCreateCollection(): boolean {
return this.organization?.canCreateNewCollections; return this.organization?.canCreateNewCollections;
} }

View File

@@ -6,7 +6,7 @@
[searchText]="currentSearchText$ | async" [searchText]="currentSearchText$ | async"
(onAddCipher)="addCipher()" (onAddCipher)="addCipher()"
(onAddCollection)="addCollection()" (onAddCollection)="addCollection()"
(onEditCollection)="editCollection(selectedCollection.node, $event.tab)" (onEditCollection)="editCollection(selectedCollection.node, $event.tab, $event.readonly)"
(onDeleteCollection)="deleteCollection(selectedCollection.node)" (onDeleteCollection)="deleteCollection(selectedCollection.node)"
(searchTextChanged)="filterSearchText($event)" (searchTextChanged)="filterSearchText($event)"
></app-org-vault-header> ></app-org-vault-header>

View File

@@ -736,7 +736,7 @@ export class VaultComponent implements OnInit, OnDestroy {
try { try {
if (event.type === "viewAttachments") { if (event.type === "viewAttachments") {
await this.editCipherAttachments(event.item); await this.editCipherAttachments(event.item);
} else if (event.type === "viewCollections") { } else if (event.type === "viewCipherCollections") {
await this.editCipherCollections(event.item); await this.editCipherCollections(event.item);
} else if (event.type === "clone") { } else if (event.type === "clone") {
await this.cloneCipher(event.item); await this.cloneCipher(event.item);
@@ -761,9 +761,9 @@ export class VaultComponent implements OnInit, OnDestroy {
} else if (event.type === "copyField") { } else if (event.type === "copyField") {
await this.copy(event.item, event.field); await this.copy(event.item, event.field);
} else if (event.type === "editCollection") { } else if (event.type === "editCollection") {
await this.editCollection(event.item, CollectionDialogTabType.Info); await this.editCollection(event.item, CollectionDialogTabType.Info, event.readonly);
} else if (event.type === "viewCollectionAccess") { } else if (event.type === "viewCollectionAccess") {
await this.editCollection(event.item, CollectionDialogTabType.Access); await this.editCollection(event.item, CollectionDialogTabType.Access, event.readonly);
} else if (event.type === "bulkEditCollectionAccess") { } else if (event.type === "bulkEditCollectionAccess") {
await this.bulkEditCollectionAccess(event.items); await this.bulkEditCollectionAccess(event.items);
} else if (event.type === "assignToCollections") { } else if (event.type === "assignToCollections") {
@@ -1190,7 +1190,7 @@ export class VaultComponent implements OnInit, OnDestroy {
async editCollection( async editCollection(
c: CollectionView, c: CollectionView,
tab: CollectionDialogTabType, tab: CollectionDialogTabType,
readonly: boolean = false, readonly: boolean,
): Promise<void> { ): Promise<void> {
const dialog = openCollectionDialog(this.dialogService, { const dialog = openCollectionDialog(this.dialogService, {
data: { data: {

View File

@@ -8081,5 +8081,11 @@
}, },
"manageBillingFromProviderPortalMessage": { "manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal" "message": "Manage billing from the Provider Portal"
},
"viewInfo": {
"message": "View info"
},
"viewAccess": {
"message": "View access"
} }
} }

View File

@@ -13,7 +13,7 @@
</h1> </h1>
<p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p> <p *ngIf="subtitle" bitTypography="body1">{{ subtitle }}</p>
</div> </div>
<div class="tw-mb-auto tw-mx-auto tw-grid"> <div class="tw-mb-auto tw-mx-auto tw-flex tw-flex-col tw-items-center">
<div <div
class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8" class="tw-rounded-xl tw-mb-9 tw-mx-auto sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
> >

View File

@@ -9,7 +9,6 @@ export enum FeatureFlag {
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
VaultOnboarding = "vault-onboarding", VaultOnboarding = "vault-onboarding",
GeneratorToolsModernization = "generator-tools-modernization", GeneratorToolsModernization = "generator-tools-modernization",
KeyRotationImprovements = "key-rotation-improvements",
FlexibleCollectionsMigration = "flexible-collections-migration", FlexibleCollectionsMigration = "flexible-collections-migration",
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
EnableConsolidatedBilling = "enable-consolidated-billing", EnableConsolidatedBilling = "enable-consolidated-billing",
@@ -37,7 +36,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.FlexibleCollectionsV1]: FALSE, [FeatureFlag.FlexibleCollectionsV1]: FALSE,
[FeatureFlag.VaultOnboarding]: FALSE, [FeatureFlag.VaultOnboarding]: FALSE,
[FeatureFlag.GeneratorToolsModernization]: FALSE, [FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.KeyRotationImprovements]: FALSE,
[FeatureFlag.FlexibleCollectionsMigration]: FALSE, [FeatureFlag.FlexibleCollectionsMigration]: FALSE,
[FeatureFlag.ShowPaymentMethodWarningBanners]: FALSE, [FeatureFlag.ShowPaymentMethodWarningBanners]: FALSE,
[FeatureFlag.EnableConsolidatedBilling]: FALSE, [FeatureFlag.EnableConsolidatedBilling]: FALSE,

View File

@@ -87,6 +87,13 @@ export class CollectionView implements View, ITreeNodeObject {
: org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections; : org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections;
} }
/**
* Returns true if the user can view collection info and access in a read-only state from the individual vault
*/
canViewCollectionInfo(org: Organization | undefined): boolean {
return false;
}
static fromJSON(obj: Jsonify<CollectionView>) { static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView(new Collection()), obj); return Object.assign(new CollectionView(new Collection()), obj);
} }