1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 17:53:39 +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,9 +1,5 @@
<div *ngIf="selfHosted" class="page-header"> <bit-section>
<h1>{{ "subscription" | i18n }}</h1> <h2 *ngIf="!selfHosted" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
</div>
<div *ngIf="!selfHosted" class="tabbed-header">
<h1>{{ "goPremium" | i18n }}</h1>
</div>
<bit-callout <bit-callout
type="info" type="info"
*ngIf="canAccessPremium$ | async" *ngIf="canAccessPremium$ | async"
@@ -16,41 +12,45 @@
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p> <p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul"> <ul class="bwi-ul">
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }} {{ "premiumSignUpStorage" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }} {{ "premiumSignUpTwoStepOptions" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }} {{ "premiumSignUpEmergency" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }} {{ "premiumSignUpReports" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }} {{ "premiumSignUpTotp" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }} {{ "premiumSignUpSupport" | i18n }}
</li> </li>
<li> <li>
<i class="bwi bwi-check text-success bwi-li" aria-hidden="true"></i> <i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }} {{ "premiumSignUpFuture" | i18n }}
</li> </li>
</ul> </ul>
<p class="text-lg" [ngClass]="{ 'mb-0': !selfHosted }"> <p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !selfHosted }">
{{ {{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount "premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}} }}
<a routerLink="/create-organization" [queryParams]="{ plan: 'families' }">{{ <a
"bitwardenFamiliesPlan" | i18n bitLink
}}</a> linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>{{ "bitwardenFamiliesPlan" | i18n }}</a
>
</p> </p>
<a <a
bitButton bitButton
@@ -63,67 +63,81 @@
{{ "purchasePremium" | i18n }} {{ "purchasePremium" | i18n }}
</a> </a>
</bit-callout> </bit-callout>
<ng-container *ngIf="selfHosted"> </bit-section>
<p>{{ "uploadLicenseFilePremium" | i18n }}</p> <bit-section *ngIf="selfHosted">
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate> <p bitTypography="body1">{{ "uploadLicenseFilePremium" | i18n }}</p>
<div class="form-group"> <form [formGroup]="licenseForm" [bitSubmit]="submit">
<label for="file">{{ "licenseFile" | i18n }}</label> <bit-form-field>
<input type="file" id="file" class="form-control-file" name="file" required /> <bit-label>{{ "licenseFile" | i18n }}</bit-label>
<small class="form-text text-muted">{{ <div>
"licenseFileDesc" | i18n: "bitwarden_premium_license.json" <button bitButton type="button" buttonType="secondary" (click)="fileSelector.click()">
}}</small> {{ "chooseFile" | i18n }}
</button>
{{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }}
</div> </div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading"> <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">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input <input
id="additionalStorage" bitInput
class="form-control" formControlName="additionalStorage"
type="number" type="number"
name="AdditionalStorageGb"
[(ngModel)]="additionalStorage"
min="0"
max="99"
step="1" step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}" placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/> />
<small class="text-muted form-text">{{ <bit-hint>{{
"additionalStorageIntervalDesc" "additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n) | i18n: "1 GB" : (storageGbPrice | currency: "$") : ("year" | i18n)
}}</small> }}</bit-hint>
</bit-form-field>
</div> </div>
</div> </bit-section>
<h2 class="spaced-header">{{ "summary" | i18n }}</h2> <bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br /> {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times; {{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB &times;
{{ storageGbPrice | currency: "$" }} = {{ storageGbPrice | currency: "$" }} =
{{ additionalStorageTotal | currency: "$" }} {{ additionalStorageTotal | currency: "$" }}
<hr class="my-3" /> <hr class="tw-my-3" />
<h2 class="spaced-header mb-4">{{ "paymentInformation" | i18n }}</h2> </bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<app-payment [hideBank]="true"></app-payment> <app-payment [hideBank]="true"></app-payment>
<app-tax-info></app-tax-info> <app-tax-info></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-sm">
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}
<br /> <br />
<ng-container> <ng-container>
{{ "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-w-1/4 tw-ml-0" />
<p class="text-lg"> <p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }} <strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p> </p>
</div> </div>
<small class="text-muted font-italic">{{ "paymentChargedAnnually" | i18n }}</small> <p bitTypography="body2">{{ "paymentChargedAnnually" | i18n }}</p>
<button type="submit" bitButton [loading]="form.loading"> <button type="submit" bitButton bitFormButton>
{{ "submit" | i18n }} {{ "submit" | i18n }}
</button> </button>
</bit-section>
</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,7 +78,6 @@ 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()) {
@@ -85,12 +90,12 @@ export class PremiumComponent implements OnInit {
} }
const fd = new FormData(); const fd = new FormData();
fd.append("license", files[0]); fd.append("license", this.licenseFile);
this.formPromise = this.apiService.postAccountLicense(fd).then(() => { await this.apiService.postAccountLicense(fd).then(() => {
return this.finalizePremium(); return this.finalizePremium();
}); });
} else { } else {
this.formPromise = this.paymentComponent await this.paymentComponent
.createPaymentToken() .createPaymentToken()
.then((result) => { .then((result) => {
const fd = new FormData(); const fd = new FormData();
@@ -114,11 +119,7 @@ export class PremiumComponent implements OnInit {
} }
}); });
} }
await this.formPromise; };
} catch (e) {
this.logService.error(e);
}
}
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> >
</bit-form-field>
</div> </div>
</div> </div>
<div class="row mb-4"> <div>
<div class="form-group col-sm"> <bit-form-control>
<div class="form-check">
<input <input
id="limitSubscription" bitCheckbox
class="form-check-input" formControlName="limitSubscription"
type="checkbox" type="checkbox"
name="LimitSubscription"
[(ngModel)]="limitSubscription"
(change)="limitSubscriptionChanged()" (change)="limitSubscriptionChanged()"
/> />
<label for="limitSubscription">{{ "limitSubscription" | i18n }}</label> <bit-label>{{ "limitSubscription" | i18n }}</bit-label>
<bit-hint> {{ "limitSubscriptionDesc" | i18n }}</bit-hint>
</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> >
</bit-form-field>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> <button bitButton buttonType="primary" bitFormButton type="submit">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> {{ "save" | i18n }}
<span>{{ "save" | i18n }}</span>
</button> </button>
</div>
</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();
this.destroy$.complete();
}
submit = async () => {
this.adjustSubscriptionForm.markAllAsTouched();
if (this.adjustSubscriptionForm.invalid) {
return;
}
const request = new OrganizationSubscriptionUpdateRequest( const request = new OrganizationSubscriptionUpdateRequest(
this.additionalSeatCount, this.additionalSeatCount,
this.newMaxSeats, this.adjustSubscriptionForm.value.newMaxSeats,
);
this.formPromise = this.organizationApiService.updatePasswordManagerSeats(
this.organizationId,
request,
); );
await this.organizationApiService.updatePasswordManagerSeats(this.organizationId, request);
await this.formPromise; this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated"));
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("subscriptionUpdated"),
);
} catch (e) {
this.logService.error(e);
}
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,142 +1,153 @@
<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>
{{ selectedFile?.name ?? ("noFileChosen" | i18n) }}
</div> </div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading"> <input
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i> #fileSelector
<span>{{ "submit" | i18n }}</span> 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"
> >
<bit-section>
<app-org-info <app-org-info
(changedBusinessOwned)="changedOwnedBusiness()" (changedBusinessOwned)="changedOwnedBusiness()"
[formGroup]="formGroup" [formGroup]="formGroup"
[createOrganization]="createOrganization" [createOrganization]="createOrganization"
[isProvider]="!!providerId" [isProvider]="!!providerId"
[acceptingSponsorship]="acceptingSponsorship" [acceptingSponsorship]="acceptingSponsorship"
></app-org-info> >
<h2 class="mt-5">{{ "chooseYourPlan" | i18n }}</h2> </app-org-info>
<div *ngFor="let selectableProduct of selectableProducts" class="form-check form-check-block"> </bit-section>
<input <bit-section>
class="form-check-input" <h2 bitTypography="h2">{{ "chooseYourPlan" | i18n }}</h2>
type="radio" <div *ngFor="let selectableProduct of selectableProducts">
name="product" <bit-radio-group formControlName="product" [block]="true">
id="product{{ selectableProduct.product }}" <bit-radio-button [value]="selectableProduct.product" (change)="changedProduct()">
[value]="selectableProduct.product" <bit-label>{{ selectableProduct.nameLocalizationKey | i18n }}</bit-label>
formControlName="product" <bit-hint class="tw-text-sm"
(change)="changedProduct()" >{{ selectableProduct.descriptionLocalizationKey | i18n: "1" }}
/>
<label class="form-check-label" for="product{{ selectableProduct.product }}">
{{ selectableProduct.nameLocalizationKey | i18n }}
<small class="mb-1">{{ selectableProduct.descriptionLocalizationKey | i18n: "1" }}</small>
<ng-container <ng-container
*ngIf="selectableProduct.product === productTypes.Enterprise; else nonEnterprisePlans" *ngIf="selectableProduct.product === productTypes.Enterprise; else nonEnterprisePlans"
> >
<small>• {{ "includeAllTeamsFeatures" | i18n }}</small> <ul class="tw-pl-0 tw-list-inside tw-mb-0">
<small *ngIf="selectableProduct.hasSelfHost">{{ "onPremHostingOptional" | i18n }}</small> <li>{{ "includeAllTeamsFeatures" | i18n }}</li>
<small *ngIf="selectableProduct.hasSso">{{ "includeSsoAuthentication" | i18n }}</small> <li *ngIf="selectableProduct.hasSelfHost">{{ "onPremHostingOptional" | i18n }}</li>
<small *ngIf="selectableProduct.hasPolicies" <li *ngIf="selectableProduct.hasSso">{{ "includeSsoAuthentication" | i18n }}</li>
>• {{ "includeEnterprisePolicies" | i18n }}</small <li *ngIf="selectableProduct.hasPolicies">
> {{ "includeEnterprisePolicies" | i18n }}
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization" </li>
> <li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
{{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }} {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
</small> </li>
</ul>
</ng-container> </ng-container>
<ng-template #nonEnterprisePlans> <ng-template #nonEnterprisePlans>
<ng-container <ng-container
*ngIf="selectableProduct.product === productTypes.Teams; else fullFeatureList" *ngIf="selectableProduct.product === productTypes.Teams; else fullFeatureList"
> >
<small>• {{ "includeAllTeamsStarterFeatures" | i18n }}</small> <ul class="tw-pl-0 tw-list-inside tw-mb-0">
<small>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</small> <li>{{ "includeAllTeamsStarterFeatures" | i18n }}</li>
<small>{{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</small> <li>{{ "chooseMonthlyOrAnnualBilling" | i18n }}</li>
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization"> <li>{{ "abilityToAddMoreThanNMembers" | i18n: 10 }}</li>
• {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }} <li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
</small> {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
</li>
</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 }}
<small </li>
<li
*ngIf=" *ngIf="
selectableProduct.product != productTypes.Free && selectableProduct.product != productTypes.Free &&
selectableProduct.product != productTypes.TeamsStarter && selectableProduct.product != productTypes.TeamsStarter &&
selectableProduct.PasswordManager.maxSeats selectableProduct.PasswordManager.maxSeats
" "
>
{{ "addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}</small
> >
<small *ngIf="!selectableProduct.PasswordManager.maxSeats" {{ "addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxSeats }}
>• {{ "addShareUnlimitedUsers" | i18n }}</small </li>
> <li *ngIf="!selectableProduct.PasswordManager.maxSeats">
<small *ngIf="selectableProduct.PasswordManager.maxCollections" {{ "addShareUnlimitedUsers" | i18n }}
> </li>
<li *ngIf="selectableProduct.PasswordManager.maxCollections">
{{ {{
"limitedCollections" | i18n: selectableProduct.PasswordManager.maxCollections "limitedCollections" | i18n: selectableProduct.PasswordManager.maxCollections
}}</small }}
> </li>
<small *ngIf="selectableProduct.PasswordManager.maxAdditionalSeats" <li *ngIf="selectableProduct.PasswordManager.maxAdditionalSeats">
>
{{ {{
"addShareLimitedUsers" | i18n: selectableProduct.PasswordManager.maxAdditionalSeats "addShareLimitedUsers"
}}</small | i18n: selectableProduct.PasswordManager.maxAdditionalSeats
> }}
<small *ngIf="!selectableProduct.PasswordManager.maxCollections" </li>
>• {{ "createUnlimitedCollections" | i18n }}</small <li *ngIf="!selectableProduct.PasswordManager.maxCollections">
> {{ "createUnlimitedCollections" | i18n }}
<small *ngIf="selectableProduct.PasswordManager.baseStorageGb" </li>
> <li *ngIf="selectableProduct.PasswordManager.baseStorageGb">
{{ {{
"gbEncryptedFileStorage" "gbEncryptedFileStorage"
| i18n: selectableProduct.PasswordManager.baseStorageGb + "GB" | i18n: selectableProduct.PasswordManager.baseStorageGb + "GB"
}}</small }}
> </li>
<small *ngIf="selectableProduct.hasGroups" <li *ngIf="selectableProduct.hasGroups">
> {{ "controlAccessWithGroups" | i18n }}</small {{ "controlAccessWithGroups" | i18n }}
> </li>
<small *ngIf="selectableProduct.hasApi">{{ "trackAuditLogs" | i18n }}</small> <li *ngIf="selectableProduct.hasApi">{{ "trackAuditLogs" | i18n }}</li>
<small *ngIf="selectableProduct.hasDirectory" <li *ngIf="selectableProduct.hasDirectory">
> {{ "syncUsersFromDirectory" | i18n }}</small {{ "syncUsersFromDirectory" | i18n }}
> </li>
<small *ngIf="selectableProduct.hasSelfHost" <li *ngIf="selectableProduct.hasSelfHost">
> {{ "onPremHostingOptional" | i18n }}</small {{ "onPremHostingOptional" | i18n }}
> </li>
<small *ngIf="selectableProduct.usersGetPremium">{{ "usersGetPremium" | i18n }}</small> <li *ngIf="selectableProduct.usersGetPremium">{{ "usersGetPremium" | i18n }}</li>
<small *ngIf="selectableProduct.product != productTypes.Free" <li *ngIf="selectableProduct.product != productTypes.Free">
> {{ "priorityCustomerSupport" | i18n }}</small {{ "priorityCustomerSupport" | i18n }}
> </li>
<small *ngIf="selectableProduct.trialPeriodDays && createOrganization" <li *ngIf="selectableProduct.trialPeriodDays && createOrganization">
>
{{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }} {{ "xDayFreeTrial" | i18n: selectableProduct.trialPeriodDays }}
</small> </li>
</ul>
</ng-template> </ng-template>
</ng-template> </ng-template>
</bit-hint>
</bit-radio-button>
<span *ngIf="selectableProduct.product != productTypes.Free"> <span *ngIf="selectableProduct.product != productTypes.Free">
<ng-container *ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"> <ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
>
{{ {{
(selectableProduct.isAnnual (selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12 ? selectableProduct.PasswordManager.basePrice / 12
@@ -174,113 +185,110 @@
}} }}
/{{ "month" | i18n }} /{{ "month" | i18n }}
</span> </span>
<span *ngIf="selectableProduct.product == productTypes.Free">{{ "freeForever" | i18n }}</span> <span *ngIf="selectableProduct.product == productTypes.Free">{{
</label> "freeForever" | i18n
}}</span>
</bit-radio-group>
</div> </div>
<div *ngIf="formGroup.value.product !== productTypes.Free"> </bit-section>
<ng-container <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>
</bit-form-field>
</div> </div>
</div> </bit-section>
</ng-container> <bit-section>
<h2 class="mt-5">{{ "addons" | i18n }}</h2> <h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div <div
class="row" class="tw-grid tw-grid-cols-12 tw-gap-4"
*ngIf=" *ngIf="
selectedPlan.PasswordManager.hasAdditionalSeatsOption && selectedPlan.PasswordManager.hasAdditionalSeatsOption &&
selectedPlan.PasswordManager.baseSeats selectedPlan.PasswordManager.baseSeats
" "
> >
<div class="form-group col-6"> <bit-form-field class="tw-col-span-6">
<label for="additionalSeats">{{ "additionalUserSeats" | i18n }}</label> <bit-label>{{ "additionalUserSeats" | 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 }}"
/> />
<small class="text-muted form-text">{{ <bit-hint class="tx-text-sm"
>{{
"userSeatsAdditionalDesc" "userSeatsAdditionalDesc"
| i18n | i18n
: selectedPlan.PasswordManager.baseSeats : selectedPlan.PasswordManager.baseSeats
: (seatPriceMonthly(selectedPlan) | currency: "$") : (seatPriceMonthly(selectedPlan) | currency: "$")
}}</small> }}
</bit-hint>
</bit-form-field>
</div> </div>
</div> <div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="row"> <bit-form-field class="tw-col-span-6">
<div class="form-group col-6"> <bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<label for="additionalStorage">{{ "additionalStorageGb" | i18n }}</label>
<input <input
id="additionalStorage" bitInput
class="form-control"
type="number" type="number"
name="additionalStorageGb"
formControlName="additionalStorage" formControlName="additionalStorage"
step="1" step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}" placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/> />
<small class="text-muted form-text">{{ <bit-hint class="tw-text-sm">{{
"additionalStorageIntervalDesc" "additionalStorageIntervalDesc"
| i18n | i18n
: "1 GB" : "1 GB"
: (additionalStoragePriceMonthly(selectedPlan) | currency: "$") : (additionalStoragePriceMonthly(selectedPlan) | currency: "$")
: ("month" | i18n) : ("month" | i18n)
}}</small> }}</bit-hint>
</bit-form-field>
</div> </div>
</div> </bit-section>
<div class="row"> <bit-section>
<div class="form-group col-6" *ngIf="selectedPlan.PasswordManager.hasPremiumAccessOption"> <div
<div class="form-check"> class="tw-grid tw-grid-cols-12 tw-gap-4"
<input *ngIf="selectedPlan.PasswordManager.hasPremiumAccessOption"
id="premiumAccess" >
class="form-check-input" <bit-form-control class="tw-col-span-6">
type="checkbox" <bit-label>{{ "premiumAccess" | i18n }}</bit-label>
name="premiumAccessAddon" <input type="checkbox" bitCheckbox formControlName="premiumAccessAddon" />
formControlName="premiumAccessAddon" <bit-hint class="tw-text-sm">{{
/>
<label for="premiumAccess" class="form-check-label bold">{{
"premiumAccess" | i18n
}}</label>
</div>
<small class="text-muted form-text">{{
"premiumAccessDesc" | i18n: (3.33 | currency: "$") : ("month" | i18n) "premiumAccessDesc" | i18n: (3.33 | currency: "$") : ("month" | i18n)
}}</small> }}</bit-hint>
</bit-form-control>
</div> </div>
</div> </bit-section>
<h2 class="spaced-header">{{ "summary" | i18n }}</h2> <bit-section *ngFor="let selectablePlan of selectablePlans">
<div class="form-check form-check-block" *ngFor="let selectablePlan of selectablePlans"> <h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
<input <bit-radio-group formControlName="plan">
class="form-check-input" <bit-radio-button
type="radio" type="radio"
name="plan"
id="interval{{ selectablePlan.type }}" id="interval{{ selectablePlan.type }}"
[value]="selectablePlan.type" [value]="selectablePlan.type"
formControlName="plan" >
/> <bit-label>{{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }}</bit-label>
<label class="form-check-label" for="interval{{ selectablePlan.type }}"> <bit-hint *ngIf="selectablePlan.isAnnual">
<ng-container *ngIf="selectablePlan.isAnnual"> <p
{{ "annually" | i18n }} class="tw-mb-0"
<small *ngIf="selectablePlan.PasswordManager.basePrice"> bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}: {{ "basePrice" | i18n }}:
{{ {{
(selectablePlan.isAnnual (selectablePlan.isAnnual
@@ -292,7 +300,7 @@
{{ "monthAbbr" | i18n }} {{ "monthAbbr" | i18n }}
= =
<ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship"> <ng-container *ngIf="acceptingSponsorship; else notAcceptingSponsorship">
<span style="text-decoration: line-through">{{ <span class="tw-line-through">{{
selectablePlan.PasswordManager.basePrice | currency: "$" selectablePlan.PasswordManager.basePrice | currency: "$"
}}</span> }}</span>
{{ "freeWithSponsorship" | i18n }} {{ "freeWithSponsorship" | i18n }}
@@ -301,8 +309,12 @@
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }} {{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "year" | i18n }} /{{ "year" | i18n }}
</ng-template> </ng-template>
</small> </p>
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"> <p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats" <span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span >{{ "additionalUsers" | i18n }}:</span
> >
@@ -320,8 +332,12 @@
| currency: "$" | currency: "$"
}} }}
/{{ "year" | i18n }} /{{ "year" | i18n }}
</small> </p>
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"> <p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}: {{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times; {{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ {{
@@ -332,19 +348,26 @@
}} }}
&times; 12 {{ "monthAbbr" | i18n }} = &times; 12 {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }} {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }}
</small> </p>
</ng-container> </bit-hint>
<ng-container *ngIf="!selectablePlan.isAnnual"> <bit-hint *ngIf="!selectablePlan.isAnnual">
{{ "monthly" | i18n }} <p
<small *ngIf="selectablePlan.PasswordManager.basePrice"> class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.basePrice"
>
{{ "basePrice" | i18n }}: {{ "basePrice" | i18n }}:
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }} {{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }} {{ "monthAbbr" | i18n }}
= =
{{ selectablePlan.PasswordManager.basePrice | currency: "$" }} {{ selectablePlan.PasswordManager.basePrice | currency: "$" }}
/{{ "month" | i18n }} /{{ "month" | i18n }}
</small> </p>
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"> <p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalSeatsOption"
>
<span *ngIf="selectablePlan.PasswordManager.baseSeats" <span *ngIf="selectablePlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span >{{ "additionalUsers" | i18n }}:</span
> >
@@ -357,44 +380,49 @@
| currency: "$" | currency: "$"
}} }}
/{{ "month" | i18n }} /{{ "month" | i18n }}
</small> </p>
<small *ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"> <p
class="tw-mb-0"
bitTypography="body2"
*ngIf="selectablePlan.PasswordManager.hasAdditionalStorageOption"
>
{{ "additionalStorageGb" | i18n }}: {{ "additionalStorageGb" | i18n }}:
{{ formGroup.controls["additionalStorage"].value || 0 }} &times; {{ formGroup.controls["additionalStorage"].value || 0 }} &times;
{{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} {{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ "monthAbbr" | i18n }} = {{ "monthAbbr" | i18n }} =
{{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }} {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }}
</small> </p>
</ng-container> </bit-hint>
</label> </bit-radio-button>
</div> </bit-radio-group>
</div> </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,12 +532,15 @@ 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;
} }
try {
const doSubmit = async (): Promise<string> => { const doSubmit = async (): Promise<string> => {
let orgId: string = null; let orgId: string = null;
if (this.createOrganization) { if (this.createOrganization) {
@@ -589,10 +597,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.onSuccess.emit({ organizationId: organizationId }); this.onSuccess.emit({ organizationId: organizationId });
// TODO: No one actually listening to this message? // TODO: No one actually listening to this message?
this.messagingService.send("organizationCreated", { organizationId }); this.messagingService.send("organizationCreated", { organizationId });
} catch (e) { };
this.logService.error(e);
}
}
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">
<button type="button" bitMenuItem (click)="edit(false)">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }} {{ "editInfo" | i18n }}
</button> </button>
<button *ngIf="canEditCollection" type="button" bitMenuItem (click)="access()"> <button type="button" bitMenuItem (click)="access(false)">
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }} {{ "access" | i18n }}
</button> </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,6 +95,8 @@
[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)"

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>
<ng-container *ngIf="canEditCollection">
<button <button
type="button" type="button"
*ngIf="canEditCollection"
bitMenuItem bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info)" (click)="editCollection(CollectionDialogTabType.Info, false)"
> >
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }} {{ "editInfo" | i18n }}
</button> </button>
<button <button
type="button" type="button"
*ngIf="canEditCollection"
bitMenuItem bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access)" (click)="editCollection(CollectionDialogTabType.Access, false)"
> >
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }} {{ "access" | i18n }}
</button> </button>
</ng-container>
<ng-container
*ngIf="flexibleCollectionsV1Enabled && !canEditCollection && canViewCollectionInfo"
>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(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);
} }