1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-01 01:03:39 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-10-23 16:30:32 +02:00
256 changed files with 6426 additions and 2484 deletions

1
.github/CODEOWNERS vendored
View File

@@ -8,6 +8,7 @@
apps/desktop/desktop_native @bitwarden/team-platform-dev
apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev
## No ownership for Cargo.lock and Cargo.toml to allow dependency updates
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml

View File

@@ -400,7 +400,16 @@
reviewers: ["team:team-vault-dev"],
},
{
matchPackageNames: ["aes", "big-integer", "cbc", "rsa", "russh-cryptovec", "sha2"],
matchPackageNames: [
"aes",
"big-integer",
"cbc",
"rsa",
"russh-cryptovec",
"sha2",
"memsec",
"linux-keyutils",
],
description: "Key Management owned dependencies",
commitMessagePrefix: "[deps] KM:",
reviewers: ["team:team-key-management-dev"],

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ npm-debug.log
# Build directories
dist
build
target
.angular/cache
.flatpak
.flatpak-repo

View File

@@ -5733,6 +5733,9 @@
"confirmKeyConnectorDomain": {
"message": "Confirm Key Connector domain"
},
"atRiskLoginsSecured": {
"message": "Great job securing your at-risk logins!"
},
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."

View File

@@ -33,6 +33,8 @@ import { AccountComponent } from "./account.component";
import { CurrentAccountComponent } from "./current-account.component";
import { AccountSwitcherService } from "./services/account-switcher.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account-switcher.component.html",
imports: [

View File

@@ -13,13 +13,19 @@ import { BiometricsService } from "@bitwarden/key-management";
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-account",
templateUrl: "account.component.html",
imports: [CommonModule, JslibModule, AvatarModule, ItemModule],
})
export class AccountComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() account: AvailableAccount;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() loading = new EventEmitter<boolean>();
constructor(

View File

@@ -21,6 +21,8 @@ export type CurrentAccount = {
avatarColor: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-current-account",
templateUrl: "current-account.component.html",

View File

@@ -13,6 +13,8 @@ import {
IconButtonModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "set-pin.component.html",
imports: [

View File

@@ -41,6 +41,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
import { AccountSecurityComponent } from "./account-security.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-pop-out",
template: ` <ng-content></ng-content>`,

View File

@@ -78,6 +78,8 @@ import { SetPinComponent } from "../components/set-pin.component";
import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account-security.component.html",
imports: [

View File

@@ -3,6 +3,8 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "await-desktop-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -7,6 +7,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
standalone: true,
selector: "extension-device-management",

View File

@@ -28,9 +28,17 @@ import {
],
})
export class Fido2CipherRowComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onSelected = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() cipher: CipherView;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() last: boolean;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
protected selectCipher(c: CipherView) {

View File

@@ -15,6 +15,8 @@ import { MenuModule } from "@bitwarden/components";
import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data";
import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-fido2-use-browser-link",
templateUrl: "fido2-use-browser-link.component.html",

View File

@@ -71,6 +71,8 @@ interface ViewData {
fallbackSupported: boolean;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-fido2",
templateUrl: "fido2.component.html",

View File

@@ -77,6 +77,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "autofill.component.html",
imports: [

View File

@@ -41,6 +41,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-blocked-domains",
templateUrl: "blocked-domains.component.html",
@@ -66,6 +68,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
],
})
export class BlockedDomainsComponent implements AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
new QueryList();

View File

@@ -42,6 +42,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-excluded-domains",
templateUrl: "excluded-domains.component.html",
@@ -67,6 +69,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu
],
})
export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChildren("uriInput") uriInputElements: QueryList<ElementRef<HTMLInputElement>> =
new QueryList();

View File

@@ -21,6 +21,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "notifications.component.html",
imports: [

View File

@@ -319,6 +319,7 @@ import I18nService from "../platform/services/i18n.service";
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service";
import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service";
import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service";
import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service";
@@ -488,6 +489,7 @@ export default class MainBackground {
private nativeMessagingBackground: NativeMessagingBackground;
private popupViewCacheBackgroundService: PopupViewCacheBackgroundService;
private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService;
constructor() {
// Services
@@ -684,6 +686,9 @@ export default class MainBackground {
this.globalStateProvider,
this.taskSchedulerService,
);
this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService(
this.globalStateProvider,
);
this.migrationRunner = new MigrationRunner(
this.storageService,
@@ -1045,6 +1050,7 @@ export default class MainBackground {
this.authService,
this.stateProvider,
this.securityStateService,
this.kdfConfigService,
);
this.syncServiceListener = new SyncServiceListener(
@@ -1514,6 +1520,7 @@ export default class MainBackground {
(this.eventUploadService as EventUploadService).init(true);
this.popupViewCacheBackgroundService.startObservingMessages();
this.popupRouterCacheBackgroundService.init();
await this.vaultTimeoutService.init(true);
this.fido2Background.init();

View File

@@ -5,6 +5,7 @@ import { Injectable, inject } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
Data,
NavigationEnd,
Router,
UrlSerializer,
@@ -14,7 +15,10 @@ import { filter, first, firstValueFrom, map, Observable, of, switchMap, tap } fr
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service";
import {
POPUP_ROUTE_HISTORY_KEY,
RouteHistoryCacheState,
} from "../../../platform/services/popup-view-cache-background.service";
import BrowserPopupUtils from "../../browser/browser-popup-utils";
/**
@@ -42,8 +46,7 @@ export class PopupRouterCacheService {
this.history$()
.pipe(first())
.subscribe(
(history) =>
Array.isArray(history) && history.forEach((location) => this.location.go(location)),
(history) => Array.isArray(history) && history.forEach(({ url }) => this.location.go(url)),
);
// update state when route change occurs
@@ -54,31 +57,33 @@ export class PopupRouterCacheService {
// `Location.back()` can now be called successfully
this.hasNavigated = true;
}),
filter((_event: NavigationEnd) => {
map((event) => {
const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root;
let child = state.firstChild;
while (child.firstChild) {
child = child.firstChild;
}
return !child?.data?.doNotSaveUrl;
return { event, data: child.data };
}),
switchMap((event) => this.push(event.url)),
filter(({ data }) => {
return !data?.doNotSaveUrl;
}),
switchMap(({ event, data }) => this.push(event.url, data)),
)
.subscribe();
}
history$(): Observable<string[]> {
history$(): Observable<RouteHistoryCacheState[]> {
return this.state.state$;
}
async setHistory(state: string[]): Promise<string[]> {
async setHistory(state: RouteHistoryCacheState[]): Promise<RouteHistoryCacheState[]> {
return this.state.update(() => state);
}
/** Get the last item from the history stack, or `null` if empty */
last$(): Observable<string | null> {
last$(): Observable<RouteHistoryCacheState | null> {
return this.history$().pipe(
map((history) => {
if (!history || history.length === 0) {
@@ -92,11 +97,24 @@ export class PopupRouterCacheService {
/**
* If in browser popup, push new route onto history stack
*/
private async push(url: string) {
if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) {
private async push(url: string, data: Data) {
if (
!BrowserPopupUtils.inPopup(window) ||
url === (await firstValueFrom(this.last$().pipe(map((h) => h?.url))))
) {
return;
}
await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url)));
const routeEntry: RouteHistoryCacheState = {
url,
options: {
resetRouterCacheOnTabChange: data?.resetRouterCacheOnTabChange ?? false,
},
};
await this.state.update((prevState) =>
prevState == null ? [routeEntry] : prevState.concat(routeEntry),
);
}
/**
@@ -142,13 +160,13 @@ export const popupRouterCacheGuard = ((): Observable<true | UrlTree> => {
}
return popupHistoryService.last$().pipe(
map((url: string) => {
if (!url) {
map((entry) => {
if (!entry) {
return true;
}
popupHistoryService.markCacheRestored();
return urlSerializer.parse(url);
return urlSerializer.parse(entry.url);
}),
);
}) satisfies CanActivateFn;

View File

@@ -96,11 +96,31 @@ describe("Popup router cache guard", () => {
// wait for router events subscription
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/b",
},
]);
await service.back();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
]);
});
it("does not save ignored routes", async () => {
@@ -121,6 +141,13 @@ describe("Popup router cache guard", () => {
await flushPromises();
expect(await firstValueFrom(service.history$())).toEqual(["/a"]);
expect(await firstValueFrom(service.history$())).toEqual([
{
options: {
resetRouterCacheOnTabChange: false,
},
url: "/a",
},
]);
});
});

View File

@@ -0,0 +1,55 @@
import { switchMap, filter, map, first, of } from "rxjs";
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
import { BrowserApi } from "../browser/browser-api";
import { fromChromeEvent } from "../browser/from-chrome-event";
import { POPUP_ROUTE_HISTORY_KEY } from "./popup-view-cache-background.service";
export class PopupRouterCacheBackgroundService {
private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY);
constructor(private globalStateProvider: GlobalStateProvider) {}
init() {
fromChromeEvent(chrome.tabs.onActivated)
.pipe(
switchMap((tabs) => BrowserApi.getTab(tabs[0].tabId)!),
switchMap((tab) => {
// FireFox sets the `url` to "about:blank" and won't populate the `url` until the `onUpdated` event
if (tab.url !== "about:blank") {
return of(tab);
}
return fromChromeEvent(chrome.tabs.onUpdated).pipe(
first(),
switchMap(([tabId]) => BrowserApi.getTab(tabId)!),
);
}),
map((tab) => tab.url || tab.pendingUrl),
filter((url) => !url?.startsWith(chrome.runtime.getURL(""))),
switchMap(() =>
this.popupRouteHistoryState.update((state) => {
if (!state || state.length === 0) {
return state;
}
const lastRoute = state.at(-1);
if (!lastRoute) {
return state;
}
// When the last route has resetRouterCacheOnTabChange set
// Reset the route history to empty to force the user to the default route
if (lastRoute.options?.resetRouterCacheOnTabChange) {
return [];
}
return state;
}),
),
)
.subscribe();
}
}

View File

@@ -42,6 +42,22 @@ export type ViewCacheState = {
options?: ViewCacheOptions;
};
export type RouteCacheOptions = {
/**
* When true, the route history will be reset on tab change and respective route was the last visited route.
* i.e. Upon the user re-opening the extension the route history will be empty and the user will be taken to the default route.
*/
resetRouterCacheOnTabChange?: boolean;
};
export type RouteHistoryCacheState = {
/** Route URL */
url: string;
/** Options for managing the route history cache */
options?: RouteCacheOptions;
};
/** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */
export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
POPUP_VIEW_MEMORY,
@@ -51,9 +67,9 @@ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record<ViewCacheState>(
},
);
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<string[]>(
export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition<RouteHistoryCacheState[]>(
POPUP_VIEW_MEMORY,
"popup-route-history",
"popup-route-history-details",
{
deserializer: (jsonValue) => jsonValue,
},

View File

@@ -59,6 +59,7 @@ import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service";
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component";
import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component";
@@ -76,7 +77,10 @@ import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro
import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component";
import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component";
import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component";
import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-passwords.guard";
import {
canAccessAtRiskPasswords,
hasAtRiskPasswords,
} from "../vault/popup/guards/at-risk-passwords.guard";
import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard";
import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
@@ -98,7 +102,7 @@ import { TabsV2Component } from "./tabs-v2.component";
/**
* Data properties acceptable for use in extension route objects
*/
export interface RouteDataProperties {
export interface RouteDataProperties extends RouteCacheOptions {
elevation: RouteElevation;
/**
@@ -204,7 +208,7 @@ const routes: Routes = [
path: "add-cipher",
component: AddEditV2Component,
canActivate: [authGuard, debounceNavigationGuard()],
data: { elevation: 1 } satisfies RouteDataProperties,
data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties,
runGuardsAndResolvers: "always",
},
{
@@ -214,6 +218,7 @@ const routes: Routes = [
data: {
// Above "trash"
elevation: 3,
resetRouterCacheOnTabChange: true,
} satisfies RouteDataProperties,
runGuardsAndResolvers: "always",
},
@@ -690,7 +695,7 @@ const routes: Routes = [
{
path: "at-risk-passwords",
component: AtRiskPasswordsComponent,
canActivate: [authGuard, canAccessAtRiskPasswords],
canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords],
},
{
path: "account-switcher",

View File

@@ -1,8 +1,28 @@
<bit-callout *ngIf="(pendingTasks$ | async)?.length as taskCount" type="warning" [title]="''">
<a bitLink [routerLink]="'/at-risk-passwords'">
{{
(taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural")
| i18n: taskCount.toString()
}}
</a>
</bit-callout>
@if ((currentPendingTasks$ | async)?.length > 0) {
<bit-banner
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
bannerType="warning"
[showClose]="false"
>
<a bitLink linkType="secondary" [routerLink]="'/at-risk-passwords'">
{{
((currentPendingTasks$ | async)?.length === 1
? "reviewAndChangeAtRiskPassword"
: "reviewAndChangeAtRiskPasswordsPlural"
) | i18n: (currentPendingTasks$ | async)?.length.toString()
}}
</a>
</bit-banner>
}
@if (showCompletedTasksBanner$ | async) {
<bit-banner
class="-tw-m-5 tw-flex tw-flex-col tw-pt-2 tw-px-2 tw-mb-3"
bannerType="info"
[icon]="null"
[showClose]="true"
(onClose)="successBannerDismissed()"
>
{{ "atRiskLoginsSecured" | i18n }}
</bit-banner>
}

View File

@@ -1,42 +1,47 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
import { combineLatest, map, switchMap } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
@Component({
selector: "vault-at-risk-password-callout",
imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe],
imports: [
AnchorLinkDirective,
CommonModule,
RouterModule,
CalloutModule,
I18nPipe,
BannerModule,
JslibModule,
],
providers: [AtRiskPasswordCalloutService],
templateUrl: "./at-risk-password-callout.component.html",
})
export class AtRiskPasswordCalloutComponent {
private taskService = inject(TaskService);
private cipherService = inject(CipherService);
private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
protected pendingTasks$ = this.activeAccount$.pipe(
switchMap((userId) =>
combineLatest([
this.taskService.pendingTasks$(userId),
this.cipherService.cipherViews$(userId),
]),
),
map(([tasks, ciphers]) =>
tasks.filter((t) => {
const associatedCipher = ciphers.find((c) => c.id === t.cipherId);
return (
t.type === SecurityTaskType.UpdateAtRiskCredential &&
associatedCipher &&
!associatedCipher.isDeleted
);
}),
),
showCompletedTasksBanner$ = this.activeAccount$.pipe(
switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)),
);
currentPendingTasks$ = this.activeAccount$.pipe(
switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)),
);
async successBannerDismissed() {
const updateObject: AtRiskPasswordCalloutData = {
hasInteractedWithTasks: true,
tasksBannerDismissed: true,
};
const userId = await firstValueFrom(this.activeAccount$);
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject);
}
}

View File

@@ -14,6 +14,9 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
@@ -24,6 +27,7 @@ import {
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
PasswordRepromptService,
AtRiskPasswordCalloutService,
} from "@bitwarden/vault";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
@@ -68,6 +72,9 @@ describe("AtRiskPasswordsComponent", () => {
let mockNotifications$: BehaviorSubject<NotificationView[]>;
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
let calloutDismissed$: BehaviorSubject<boolean>;
let mockAtRiskPasswordCalloutService: any;
let stateProvider: FakeStateProvider;
let mockAccountService: FakeAccountService;
const setInlineMenuVisibility = jest.fn();
const mockToastService = mock<ToastService>();
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
@@ -112,6 +119,11 @@ describe("AtRiskPasswordsComponent", () => {
mockToastService.showToast.mockClear();
mockDialogService.open.mockClear();
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
mockAccountService = {
activeAccount$: of({ id: "user" as UserId }),
activeUserId: "user" as UserId,
} as unknown as FakeAccountService;
stateProvider = new FakeStateProvider(mockAccountService);
await TestBed.configureTestingModule({
imports: [AtRiskPasswordsComponent],
@@ -141,7 +153,7 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{
@@ -152,6 +164,8 @@ describe("AtRiskPasswordsComponent", () => {
},
},
{ provide: ToastService, useValue: mockToastService },
{ provide: StateProvider, useValue: stateProvider },
{ provide: AtRiskPasswordCalloutService, useValue: mockAtRiskPasswordCalloutService },
],
})
.overrideModule(JslibModule, {

View File

@@ -41,6 +41,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import {
AtRiskPasswordCalloutService,
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
PasswordRepromptService,
@@ -75,6 +76,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
providers: [
AtRiskPasswordPageService,
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
AtRiskPasswordCalloutService,
],
selector: "vault-at-risk-passwords",
templateUrl: "./at-risk-passwords.component.html",
@@ -95,13 +97,14 @@ export class AtRiskPasswordsComponent implements OnInit {
private dialogService = inject(DialogService);
private endUserNotificationService = inject(EndUserNotificationService);
private destroyRef = inject(DestroyRef);
private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService);
/**
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
* The UI utilize a bitBadge which does not support async actions (like bitButton does).
* @protected
*/
protected launchingCipher = signal<CipherView | null>(null);
protected readonly launchingCipher = signal<CipherView | null>(null);
private activeUserData$ = this.accountService.activeAccount$.pipe(
filterOutNullish(),
@@ -199,6 +202,11 @@ export class AtRiskPasswordsComponent implements OnInit {
}
this.markTaskNotificationsAsRead();
this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, {
hasInteractedWithTasks: true,
tasksBannerDismissed: false,
});
}
private markTaskNotificationsAsRead() {

View File

@@ -267,6 +267,11 @@ export class ItemMoreOptionsComponent {
}
protected async delete() {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: { key: "deleteItemConfirmation" },

View File

@@ -41,6 +41,9 @@
</bit-spotlight>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
<!-- At-Risk callout displays as a banner and should always remain visually above all other callouts -->
<vault-at-risk-password-callout></vault-at-risk-password-callout>
<div class="tw-mb-4" *ngIf="showHasItemsVaultSpotlight$ | async">
<bit-spotlight
[title]="'hasItemsVaultNudgeTitle' | i18n"
@@ -53,7 +56,6 @@
</ul>
</bit-spotlight>
</div>
<vault-at-risk-password-callout></vault-at-risk-password-callout>
<app-vault-header-v2></app-vault-header-v2>
</ng-container>
</ng-container>

View File

@@ -1,10 +1,11 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { map, switchMap } from "rxjs";
import { combineLatest, map, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { ToastService } from "@bitwarden/components";
@@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => {
}),
);
};
export const hasAtRiskPasswords: CanActivateFn = () => {
const accountService = inject(AccountService);
const taskService = inject(TaskService);
const cipherService = inject(CipherService);
const router = inject(Router);
return accountService.activeAccount$.pipe(
filterOutNullish(),
switchMap((user) =>
combineLatest([
taskService.pendingTasks$(user.id),
cipherService.cipherViews$(user.id).pipe(
filterOutNullish(),
map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))),
),
]).pipe(
map(([tasks, ciphers]) => {
const hasAtRiskCiphers = tasks.some(
(t) =>
t.type === SecurityTaskType.UpdateAtRiskCredential &&
t.cipherId != null &&
ciphers[t.cipherId] != null &&
!ciphers[t.cipherId].isDeleted,
);
if (!hasAtRiskCiphers) {
return router.createUrlTree(["/tabs/vault"]);
}
return true;
}),
),
),
);
};

View File

@@ -852,6 +852,7 @@ export class ServiceContainer {
this.authService,
this.stateProvider,
this.securityStateService,
this.kdfConfigService,
);
this.totpService = new TotpService(this.sdkService);

View File

@@ -927,6 +927,8 @@ dependencies = [
"interprocess",
"keytar",
"libc",
"linux-keyutils",
"memsec",
"oo7",
"pin-project",
"pkcs8",
@@ -1793,6 +1795,16 @@ dependencies = [
"cc",
]
[[package]]
name = "linux-keyutils"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -1878,6 +1890,17 @@ dependencies = [
"autocfg",
]
[[package]]
name = "memsec"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
dependencies = [
"getrandom 0.2.16",
"libc",
"windows-sys 0.45.0",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -3993,6 +4016,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -4020,6 +4052,21 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -4068,6 +4115,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -4086,6 +4139,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -4104,6 +4163,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -4134,6 +4199,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -4161,6 +4232,12 @@ dependencies = [
"windows-core 0.61.0",
]
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -4179,6 +4256,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -4197,6 +4280,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -4416,9 +4505,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.1"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]

View File

@@ -39,7 +39,9 @@ homedir = "=0.3.4"
interprocess = "=2.2.1"
keytar = "=0.1.6"
libc = "=0.2.172"
linux-keyutils = "=0.2.4"
log = "=0.4.25"
memsec = "=0.7.0"
napi = "=2.16.17"
napi-build = "=2.2.0"
napi-derive = "=2.16.13"

View File

@@ -32,6 +32,7 @@ ed25519 = { workspace = true, features = ["pkcs8"] }
futures = { workspace = true }
homedir = { workspace = true }
interprocess = { workspace = true, features = ["tokio"] }
memsec = { workspace = true, features = ["alloc_ext"] }
pin-project = { workspace = true }
pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] }
rand = { workspace = true }
@@ -87,6 +88,7 @@ desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = { workspace = true }
libc = { workspace = true }
linux-keyutils = { workspace = true }
ashpd = { workspace = true }
zbus = { workspace = true, optional = true }

View File

@@ -54,7 +54,7 @@ impl SecureMemoryStore for DpapiSecretKVStore {
self.map.insert(key, padded_data);
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
self.map.get(key).map(|data| {
// A copy is created, that is then mutated by the DPAPI unprotect function.
let mut data = data.clone();

View File

@@ -0,0 +1,105 @@
use tracing::error;
use crate::secure_memory::{
secure_key::{EncryptedMemory, SecureMemoryEncryptionKey},
SecureMemoryStore,
};
/// An encrypted memory store holds a platform protected symmetric encryption key, and uses it
/// to encrypt all items it stores. The ciphertexts for the items are not specially protected. This
/// allows circumventing length and amount limitations on platform specific secure memory APIs since
/// only a single short item needs to be protected.
///
/// The key is briefly in process memory during encryption and decryption, in memory that is protected
/// from swapping to disk via mlock, and then zeroed out immediately after use.
#[allow(unused)]
pub(crate) struct EncryptedMemoryStore {
map: std::collections::HashMap<String, EncryptedMemory>,
memory_encryption_key: SecureMemoryEncryptionKey,
}
impl EncryptedMemoryStore {
#[allow(unused)]
pub(crate) fn new() -> Self {
EncryptedMemoryStore {
map: std::collections::HashMap::new(),
memory_encryption_key: SecureMemoryEncryptionKey::new(),
}
}
}
impl SecureMemoryStore for EncryptedMemoryStore {
fn put(&mut self, key: String, value: &[u8]) {
let encrypted_value = self.memory_encryption_key.encrypt(value);
self.map.insert(key, encrypted_value);
}
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
let encrypted_memory = self.map.get(key);
if let Some(encrypted_memory) = encrypted_memory {
match self.memory_encryption_key.decrypt(encrypted_memory) {
Ok(plaintext) => Some(plaintext),
Err(_) => {
error!("In memory store, decryption failed for key {}. The memory may have been tampered with. re-keying.", key);
self.memory_encryption_key = SecureMemoryEncryptionKey::new();
self.clear();
None
}
}
} else {
None
}
}
fn has(&self, key: &str) -> bool {
self.map.contains_key(key)
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
fn clear(&mut self) {
self.map.clear();
}
}
impl Drop for EncryptedMemoryStore {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_kv_store_various_sizes() {
let mut store = EncryptedMemoryStore::new();
for size in 0..=2048 {
let key = format!("test_key_{}", size);
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
store.put(key.clone(), &value);
assert!(store.has(&key), "Store should have key for size {}", size);
assert_eq!(
store.get(&key),
Some(value),
"Value mismatch for size {}",
size
);
}
}
#[test]
fn test_crud() {
let mut store = EncryptedMemoryStore::new();
let key = "test_key".to_string();
let value = vec![1, 2, 3, 4, 5];
store.put(key.clone(), &value);
assert!(store.has(&key));
assert_eq!(store.get(&key), Some(value));
store.remove(&key);
assert!(!store.has(&key));
}
}

View File

@@ -1,6 +1,9 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
mod encrypted_memory_store;
mod secure_key;
/// The secure memory store provides an ephemeral key-value store for sensitive data.
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
/// platform-specific protections are applied to prevent memory dumps or debugger access from
@@ -12,7 +15,9 @@ pub(crate) trait SecureMemoryStore {
/// Retrieves a copy of the value associated with the given key from secure memory.
/// This copy does not have additional memory protections applied, and should be zeroed when no
/// longer needed.
fn get(&self, key: &str) -> Option<Vec<u8>>;
///
/// Note: If memory was tampered with, this will re-key the store and return None.
fn get(&mut self, key: &str) -> Option<Vec<u8>>;
/// Checks if a value is stored under the given key.
fn has(&self, key: &str) -> bool;
/// Removes the value associated with the given key from secure memory.

View File

@@ -0,0 +1,96 @@
use std::ptr::NonNull;
use chacha20poly1305::{aead::Aead, Key, KeyInit};
use rand::{rng, Rng};
pub(super) const KEY_SIZE: usize = 32;
pub(super) const NONCE_SIZE: usize = 24;
/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts will result
/// in a decryption failure and panic. The key's memory contents are protected from being swapped to disk
/// via mlock.
pub(super) struct MemoryEncryptionKey(NonNull<[u8]>);
/// An encrypted memory blob that must be decrypted using the same key that it was encrypted with.
pub struct EncryptedMemory {
nonce: [u8; NONCE_SIZE],
ciphertext: Vec<u8>,
}
impl MemoryEncryptionKey {
pub fn new() -> Self {
let mut key = [0u8; KEY_SIZE];
rng().fill(&mut key);
MemoryEncryptionKey::from(&key)
}
/// Encrypts the given plaintext using the key.
#[allow(unused)]
pub(super) fn encrypt(&self, plaintext: &[u8]) -> EncryptedMemory {
let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref()));
let mut nonce = [0u8; NONCE_SIZE];
rng().fill(&mut nonce);
let ciphertext = cipher
.encrypt(chacha20poly1305::XNonce::from_slice(&nonce), plaintext)
.expect("encryption should not fail");
EncryptedMemory { nonce, ciphertext }
}
/// Decrypts the given encrypted memory using the key. A decryption failure will panic. This is
/// okay because neither the keys nor ciphertexts should ever fail to decrypt, and doing so
/// indicates that the process memory was tampered with.
#[allow(unused)]
pub(super) fn decrypt(&self, encrypted: &EncryptedMemory) -> Result<Vec<u8>, DecryptionError> {
let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref()));
cipher
.decrypt(
chacha20poly1305::XNonce::from_slice(&encrypted.nonce),
encrypted.ciphertext.as_ref(),
)
.map_err(|_| DecryptionError::CouldNotDecrypt)
}
}
impl Drop for MemoryEncryptionKey {
fn drop(&mut self) {
unsafe {
memsec::free(self.0);
}
}
}
impl From<&[u8; KEY_SIZE]> for MemoryEncryptionKey {
fn from(value: &[u8; KEY_SIZE]) -> Self {
let mut ptr: NonNull<[u8]> =
unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") };
unsafe {
std::ptr::copy_nonoverlapping(value.as_ptr(), ptr.as_mut().as_mut_ptr(), KEY_SIZE);
}
MemoryEncryptionKey(ptr)
}
}
impl AsRef<[u8]> for MemoryEncryptionKey {
fn as_ref(&self) -> &[u8] {
unsafe { self.0.as_ref() }
}
}
#[derive(Debug)]
pub(crate) enum DecryptionError {
CouldNotDecrypt,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_encryption_key() {
let key = MemoryEncryptionKey::new();
let data = b"Hello, world!";
let encrypted = key.encrypt(data);
let decrypted = key.decrypt(&encrypted).unwrap();
assert_eq!(data.as_ref(), decrypted.as_slice());
}
}

View File

@@ -0,0 +1,93 @@
use super::crypto::{MemoryEncryptionKey, KEY_SIZE};
use super::SecureKeyContainer;
use windows::Win32::Security::Cryptography::{
CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE,
CRYPTPROTECTMEMORY_SAME_PROCESS,
};
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
/// to the current process, and cannot be decrypted by other user-mode processes.
///
/// Note: Admin processes can still decrypt this memory:
/// https://blog.slowerzs.net/posts/cryptdecryptmemory/
pub(super) struct DpapiSecureKeyContainer {
dpapi_encrypted_key: [u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize],
}
// SAFETY: The encrypted data is fully owned by this struct, and not exposed outside or cloned,
// and is disposed on drop of this struct.
unsafe impl Send for DpapiSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for DpapiSecureKeyContainer {}
impl SecureKeyContainer for DpapiSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
let mut decrypted_key = self.dpapi_encrypted_key;
unsafe {
CryptUnprotectMemory(
decrypted_key.as_mut_ptr() as *mut core::ffi::c_void,
decrypted_key.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_unprotect_memory should work");
let mut key = [0u8; KEY_SIZE];
key.copy_from_slice(&decrypted_key[..KEY_SIZE]);
MemoryEncryptionKey::from(&key)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut padded_key = [0u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize];
padded_key[..KEY_SIZE].copy_from_slice(key.as_ref());
unsafe {
CryptProtectMemory(
padded_key.as_mut_ptr() as *mut core::ffi::c_void,
padded_key.len() as u32,
CRYPTPROTECTMEMORY_SAME_PROCESS,
)
}
.expect("crypt_protect_memory should work");
DpapiSecureKeyContainer {
dpapi_encrypted_key: padded_key,
}
}
fn is_supported() -> bool {
// DPAPI is supported on all Windows versions that we support.
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = DpapiSecureKeyContainer::from_key(key1);
let container2 = DpapiSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(DpapiSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,100 @@
use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
use linux_keyutils::{KeyRing, KeyRingIdentifier};
/// The keys are bound to the process keyring.
const KEY_RING_IDENTIFIER: KeyRingIdentifier = KeyRingIdentifier::Process;
/// This is an atomic global counter used to help generate unique key IDs
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Generates a unique ID for the key in the kernel keyring.
/// SAFETY: This function is safe to call from multiple threads because it uses an atomic counter.
fn make_id() -> String {
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// In case multiple processes are running, include the PID in the key ID.
let pid = std::process::id();
format!("bitwarden_desktop_{}_{}", pid, counter)
}
/// A secure key container that uses the Linux kernel keyctl API to store the key.
/// `https://man7.org/linux/man-pages/man1/keyctl.1.html`. The kernel enforces only
/// the correct process can read them, and they do not live in process memory space
/// and cannot be dumped.
pub(super) struct KeyctlSecureKeyContainer {
/// The kernel has an identifier for the key. This is randomly generated on construction.
id: String,
}
// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on drop.
// Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the key
// is accessible across threads within the same process bound.
unsafe impl Send for KeyctlSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for KeyctlSecureKeyContainer {}
impl SecureKeyContainer for KeyctlSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false)
.expect("should get process keyring");
let key = ring.search(&self.id).expect("should find key");
let mut buffer = [0u8; KEY_SIZE];
key.read(&mut buffer).expect("should read key");
MemoryEncryptionKey::from(&buffer)
}
fn from_key(data: MemoryEncryptionKey) -> Self {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, true)
.expect("should get process keyring");
let id = make_id();
ring.add_key(&id, &data).expect("should add key");
KeyctlSecureKeyContainer { id }
}
fn is_supported() -> bool {
KeyRing::from_special_id(KEY_RING_IDENTIFIER, true).is_ok()
}
}
impl Drop for KeyctlSecureKeyContainer {
fn drop(&mut self) {
let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false)
.expect("should get process keyring");
if let Ok(key) = ring.search(&self.id) {
let _ = key.invalidate();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = KeyctlSecureKeyContainer::from_key(key1);
let container2 = KeyctlSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(KeyctlSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,109 @@
use std::{ptr::NonNull, sync::LazyLock};
use super::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
/// https://man.archlinux.org/man/memfd_secret.2.en
/// The memfd_secret store protects the data using the `memfd_secret` syscall. The
/// data is inaccessible to other user-mode processes, and even to root in most cases.
/// If arbitrary data can be executed in the kernel, the data can still be retrieved:
/// https://github.com/JonathonReinhart/nosecmem
pub(super) struct MemfdSecretSecureKeyContainer {
ptr: NonNull<[u8]>,
}
// SAFETY: The pointers in this struct are allocated by `memfd_secret`, and we have full ownership.
// They are never exposed outside or cloned, and are cleaned up by drop.
unsafe impl Send for MemfdSecretSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads. Further, memfd-secret
// is accessible across threads within the same process bound.
unsafe impl Sync for MemfdSecretSecureKeyContainer {}
impl SecureKeyContainer for MemfdSecretSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
MemoryEncryptionKey::from(
&unsafe { self.ptr.as_ref() }
.try_into()
.expect("slice should be KEY_SIZE"),
)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut ptr: NonNull<[u8]> = unsafe {
memsec::memfd_secret_sized(KEY_SIZE).expect("memfd_secret_sized should work")
};
unsafe {
std::ptr::copy_nonoverlapping(
key.as_ref().as_ptr(),
ptr.as_mut().as_mut_ptr(),
KEY_SIZE,
);
}
MemfdSecretSecureKeyContainer { ptr }
}
/// Note, `memfd_secret` is only available since Linux 6.5, so fallbacks are needed.
fn is_supported() -> bool {
// To test if memfd_secret is supported, we try to allocate a 1 byte and see if that
// succeeds.
static IS_SUPPORTED: LazyLock<bool> = LazyLock::new(|| {
let Some(ptr): Option<NonNull<[u8]>> = (unsafe { memsec::memfd_secret_sized(1) })
else {
return false;
};
// Check that the pointer is readable and writable
let result = unsafe {
let ptr = ptr.as_ptr() as *mut u8;
*ptr = 30;
*ptr += 107;
*ptr == 137
};
unsafe { memsec::free_memfd_secret(ptr) };
result
});
*IS_SUPPORTED
}
}
impl Drop for MemfdSecretSecureKeyContainer {
fn drop(&mut self) {
unsafe {
memsec::free_memfd_secret(self.ptr);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = MemfdSecretSecureKeyContainer::from_key(key1);
let container2 = MemfdSecretSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(MemfdSecretSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,83 @@
use std::ptr::NonNull;
use super::crypto::MemoryEncryptionKey;
use super::crypto::KEY_SIZE;
use super::SecureKeyContainer;
/// A SecureKeyContainer that uses mlock to prevent the memory from being swapped to disk.
/// This does not provide as strong protections as other methods, but is always supported.
pub(super) struct MlockSecureKeyContainer {
ptr: NonNull<[u8]>,
}
// SAFETY: The pointers in this struct are allocated by `malloc_sized`, and we have full ownership.
// They are never exposed outside or cloned, and are cleaned up by drop.
unsafe impl Send for MlockSecureKeyContainer {}
// SAFETY: The container is non-mutable and thus safe to share between threads.
unsafe impl Sync for MlockSecureKeyContainer {}
impl SecureKeyContainer for MlockSecureKeyContainer {
fn as_key(&self) -> MemoryEncryptionKey {
MemoryEncryptionKey::from(
&unsafe { self.ptr.as_ref() }
.try_into()
.expect("slice should be KEY_SIZE"),
)
}
fn from_key(key: MemoryEncryptionKey) -> Self {
let mut ptr: NonNull<[u8]> =
unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") };
unsafe {
std::ptr::copy_nonoverlapping(
key.as_ref().as_ptr(),
ptr.as_mut().as_mut_ptr(),
KEY_SIZE,
);
}
MlockSecureKeyContainer { ptr }
}
fn is_supported() -> bool {
true
}
}
impl Drop for MlockSecureKeyContainer {
fn drop(&mut self) {
unsafe {
memsec::free(self.ptr);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
let key1 = MemoryEncryptionKey::new();
let key2 = MemoryEncryptionKey::new();
let container1 = MlockSecureKeyContainer::from_key(key1);
let container2 = MlockSecureKeyContainer::from_key(key2);
// Capture at time 1
let data_1_1 = container1.as_key();
let data_2_1 = container2.as_key();
// Capture at time 2
let data_1_2 = container1.as_key();
let data_2_2 = container2.as_key();
// Same keys should be equal
assert_eq!(data_1_1.as_ref(), data_1_2.as_ref());
assert_eq!(data_2_1.as_ref(), data_2_2.as_ref());
// Different keys should be different
assert_ne!(data_1_1.as_ref(), data_2_1.as_ref());
assert_ne!(data_1_2.as_ref(), data_2_2.as_ref());
}
#[test]
fn test_is_supported() {
assert!(MlockSecureKeyContainer::is_supported());
}
}

View File

@@ -0,0 +1,242 @@
//! This module provides hardened storage for single cryptographic keys. These are meant for encrypting large amounts of memory.
//! Some platforms restrict how many keys can be protected by their APIs, which necessitates this layer of indirection. This significantly
//! reduces the complexity of each platform specific implementation, since all that's needed is implementing protecting a single fixed sized key
//! instead of protecting many arbitrarily sized secrets. This significantly lowers the effort to maintain each implementation.
//!
//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, and a fallback implementation using mlock.
use tracing::info;
mod crypto;
#[cfg(target_os = "windows")]
mod dpapi;
#[cfg(target_os = "linux")]
mod keyctl;
#[cfg(target_os = "linux")]
mod memfd_secret;
mod mlock;
pub use crypto::EncryptedMemory;
use crate::secure_memory::secure_key::crypto::DecryptionError;
/// An ephemeral key that is protected using a platform mechanism. It is generated on construction freshly, and can be used
/// to encrypt and decrypt segments of memory. Since the key is ephemeral, persistent data cannot be encrypted with this key.
/// On Linux and Windows, in most cases the protection mechanisms prevent memory dumps/debuggers from reading the key.
///
/// Note: This can be circumvented if code can be injected into the process and is only effective in combination with the
/// memory isolation provided in `process_isolation`.
/// - https://github.com/zer1t0/keydump
#[allow(unused)]
pub(crate) struct SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer);
impl SecureMemoryEncryptionKey {
pub fn new() -> Self {
SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer::from_key(
crypto::MemoryEncryptionKey::new(),
))
}
/// Encrypts the provided plaintext using the contained key, returning an EncryptedMemory blob.
#[allow(unused)]
pub fn encrypt(&self, plaintext: &[u8]) -> crypto::EncryptedMemory {
self.0.as_key().encrypt(plaintext)
}
/// Decrypts the provided EncryptedMemory blob using the contained key, returning the plaintext.
/// If the decryption fails, that means the memory was tampered with, and the function panics.
#[allow(unused)]
pub fn decrypt(&self, encrypted: &crypto::EncryptedMemory) -> Result<Vec<u8>, DecryptionError> {
self.0.as_key().decrypt(encrypted)
}
}
/// A platform specific implementation of a key container that protects a single encryption key
/// from memory attacks.
#[allow(unused)]
trait SecureKeyContainer: Sync + Send {
/// Returns the key as a byte slice. This slice does not have additional memory protections applied.
fn as_key(&self) -> crypto::MemoryEncryptionKey;
/// Creates a new SecureKeyContainer from the provided key.
fn from_key(key: crypto::MemoryEncryptionKey) -> Self;
/// Returns true if this platform supports this secure key container implementation.
fn is_supported() -> bool;
}
#[allow(unused)]
enum CrossPlatformSecureKeyContainer {
#[cfg(target_os = "windows")]
Dpapi(dpapi::DpapiSecureKeyContainer),
#[cfg(target_os = "linux")]
Keyctl(keyctl::KeyctlSecureKeyContainer),
#[cfg(target_os = "linux")]
MemfdSecret(memfd_secret::MemfdSecretSecureKeyContainer),
Mlock(mlock::MlockSecureKeyContainer),
}
impl SecureKeyContainer for CrossPlatformSecureKeyContainer {
fn as_key(&self) -> crypto::MemoryEncryptionKey {
match self {
#[cfg(target_os = "windows")]
CrossPlatformSecureKeyContainer::Dpapi(c) => c.as_key(),
#[cfg(target_os = "linux")]
CrossPlatformSecureKeyContainer::Keyctl(c) => c.as_key(),
#[cfg(target_os = "linux")]
CrossPlatformSecureKeyContainer::MemfdSecret(c) => c.as_key(),
CrossPlatformSecureKeyContainer::Mlock(c) => c.as_key(),
}
}
fn from_key(key: crypto::MemoryEncryptionKey) -> Self {
if let Some(container) = get_env_forced_container() {
return container;
}
#[cfg(target_os = "windows")]
{
if dpapi::DpapiSecureKeyContainer::is_supported() {
info!("Using DPAPI for secure key storage");
return CrossPlatformSecureKeyContainer::Dpapi(
dpapi::DpapiSecureKeyContainer::from_key(key),
);
}
}
#[cfg(target_os = "linux")]
{
// Memfd_secret is slightly better in some cases of the kernel being compromised.
// Note that keyctl may sometimes not be available in e.g. snap. Memfd_secret is
// not available on kernels older than 6.5 while keyctl is supported since 2.6.
//
// Note: This may prevent the system from hibernating but not sleeping. Hibernate
// would write the memory to disk, exposing the keys. If this is an issue,
// the environment variable `SECURE_KEY_CONTAINER_BACKEND` can be used
// to force the use of keyctl or mlock.
if memfd_secret::MemfdSecretSecureKeyContainer::is_supported() {
info!("Using memfd_secret for secure key storage");
return CrossPlatformSecureKeyContainer::MemfdSecret(
memfd_secret::MemfdSecretSecureKeyContainer::from_key(key),
);
}
if keyctl::KeyctlSecureKeyContainer::is_supported() {
info!("Using keyctl for secure key storage");
return CrossPlatformSecureKeyContainer::Keyctl(
keyctl::KeyctlSecureKeyContainer::from_key(key),
);
}
}
// Falling back to mlock means that the key is accessible via memory dumping.
info!("Falling back to mlock for secure key storage");
CrossPlatformSecureKeyContainer::Mlock(mlock::MlockSecureKeyContainer::from_key(key))
}
fn is_supported() -> bool {
// Mlock is always supported as a fallback.
true
}
}
fn get_env_forced_container() -> Option<CrossPlatformSecureKeyContainer> {
let env_var = std::env::var("SECURE_KEY_CONTAINER_BACKEND");
match env_var.as_deref() {
#[cfg(target_os = "windows")]
Ok("dpapi") => {
info!("Forcing DPAPI secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Dpapi(
dpapi::DpapiSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
#[cfg(target_os = "linux")]
Ok("memfd_secret") => {
info!("Forcing memfd_secret secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::MemfdSecret(
memfd_secret::MemfdSecretSecureKeyContainer::from_key(
crypto::MemoryEncryptionKey::new(),
),
))
}
#[cfg(target_os = "linux")]
Ok("keyctl") => {
info!("Forcing keyctl secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Keyctl(
keyctl::KeyctlSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
Ok("mlock") => {
info!("Forcing mlock secure key container via environment variable");
Some(CrossPlatformSecureKeyContainer::Mlock(
mlock::MlockSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()),
))
}
_ => {
info!(
"{} is not a valid secure key container backend, using automatic selection",
env_var.unwrap_or_default()
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiple_keys() {
// Create 20 different keys
let original_keys: Vec<crypto::MemoryEncryptionKey> = (0..20)
.map(|_| crypto::MemoryEncryptionKey::new())
.collect();
// Store them in secure containers
let containers: Vec<CrossPlatformSecureKeyContainer> = original_keys
.iter()
.map(|key| {
let key_bytes: &[u8; crypto::KEY_SIZE] = key.as_ref().try_into().unwrap();
CrossPlatformSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::from(
key_bytes,
))
})
.collect();
// Read all keys back and validate they match the originals
for (i, (original_key, container)) in
original_keys.iter().zip(containers.iter()).enumerate()
{
let retrieved_key = container.as_key();
assert_eq!(
original_key.as_ref(),
retrieved_key.as_ref(),
"Key {} should match after storage and retrieval",
i
);
}
// Verify all keys are different from each other
for i in 0..original_keys.len() {
for j in (i + 1)..original_keys.len() {
assert_ne!(
original_keys[i].as_ref(),
original_keys[j].as_ref(),
"Keys {} and {} should be different",
i,
j
);
}
}
// Read keys back a second time to ensure consistency
for (i, (original_key, container)) in
original_keys.iter().zip(containers.iter()).enumerate()
{
let retrieved_key_again = container.as_key();
assert_eq!(
original_key.as_ref(),
retrieved_key_again.as_ref(),
"Key {} should still match on second retrieval",
i
);
}
}
}

View File

@@ -12,6 +12,8 @@ import {
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "set-pin.component.html",
imports: [

View File

@@ -20,6 +20,8 @@ import {
import { UserVerificationComponent } from "../app/components/user-verification.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-delete-account",
templateUrl: "delete-account.component.html",

View File

@@ -0,0 +1,91 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<ng-container bitDialogTitle>
@let title = (multiStepSubmit | async)[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = (multiStepSubmit | async)[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
<div class="tw-flex tw-flex-col">
@let showBadge = firstTimeDialog();
@if (showBadge) {
<span bitBadge variant="info" class="tw-w-28 tw-my-2"> {{ "availableNow" | i18n }}</span>
}
<span>
{{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }}
@if (!firstTimeDialog) {
<span class="tw-text-muted tw-font-normal tw-text-sm">
{{ policy.name | i18n }}
</span>
}
</span>
</div>
</ng-template>
<ng-template #step1Title>
{{ "howToTurnOnAutoConfirm" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@if (autoConfirmEnabled$ | async) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
{{ "openExtension" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "close" | i18n }}
</button>
</ng-template>

View File

@@ -0,0 +1,249 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
tap,
} from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "./policy-edit-dialog.component";
export type MultiStepSubmit = {
sideEffect: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
};
/**
* Custom policy dialog component for Auto-Confirm policy.
* Satisfies the PolicyDialogComponent interface structurally
* via its static open() function.
*/
@Component({
templateUrl: "auto-confirm-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected readonly firstTimeDialog = signal(false);
protected readonly currentStep = signal(0);
protected multiStepSubmit: Observable<MultiStepSubmit[]> = of([]);
protected autoConfirmEnabled$: Observable<boolean> = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false),
);
private readonly submitPolicy: Signal<TemplateRef<unknown> | undefined> = viewChild("step0");
private readonly openExtension: Signal<TemplateRef<unknown> | undefined> = viewChild("step1");
private readonly submitPolicyTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step0Title");
private readonly openExtensionTitle: Signal<TemplateRef<unknown> | undefined> = viewChild("step1Title");
override policyComponent: AutoConfirmPolicyEditComponent | undefined;
constructor(
@Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
configService: ConfigService,
keyService: KeyService,
private policyService: PolicyService,
private router: Router,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
configService,
keyService,
);
this.firstTimeDialog.set(data.firstTimeDialog ?? false);
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
async ngAfterViewInit() {
await super.ngAfterViewInit();
if (this.policyComponent) {
this.saveDisabled$ = combineLatest([
this.autoConfirmEnabled$,
this.policyComponent.enabled.valueChanges.pipe(
startWith(this.policyComponent.enabled.value),
),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false),
tap((singleOrgPolicyEnabled) =>
this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled),
),
map((singleOrgPolicyEnabled) => [
{
sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false),
footerContent: this.submitPolicy,
titleContent: this.submitPolicyTitle,
},
{
sideEffect: () => this.openBrowserExtension(),
footerContent: this.openExtension,
titleContent: this.openExtensionTitle,
},
]),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
private async handleSubmit(singleOrgEnabled: boolean) {
if (!singleOrgEnabled) {
await this.submitSingleOrg();
}
await this.submitAutoConfirm();
}
/**
* Triggers policy submission for auto confirm.
* @returns boolean: true if multi-submit workflow should continue, false otherwise.
*/
private async submitAutoConfirm() {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
autoConfirmRequest,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!this.policyComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
private async submitSingleOrg(): Promise<void> {
const singleOrgRequest: PolicyRequest = {
type: PolicyType.SingleOrg,
enabled: true,
data: null,
};
await this.policyApiService.putPolicy(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
);
}
private async openBrowserExtension() {
await this.router.navigate(["/browser-extension-prompt"], {
queryParams: { url: "AutoConfirm" },
});
}
submit = async () => {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
await multiStepSubmit[this.currentStep()].sideEffect();
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
this.policyComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (
dialogService: DialogService,
config: DialogConfig<AutoConfirmPolicyDialogData>,
) => {
return dialogService.open<PolicyEditDialogResult>(AutoConfirmPolicyDialogComponent, config);
};
}

View File

@@ -8,8 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component";
/**
* Interface for policy dialog components.
* Any component that implements this interface can be used as a custom policy edit dialog.
*/
export interface PolicyDialogComponent {
open: (
dialogService: DialogService,
config: DialogConfig<PolicyEditDialogData>,
) => DialogRef<PolicyEditDialogResult>;
}
/**
* A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing.
@@ -37,9 +49,8 @@ export abstract class BasePolicyEditDefinition {
/**
* The dialog component that will be opened when editing this policy.
* This allows customizing the look and feel of each policy's dialog contents.
* If not specified, defaults to {@link PolicyEditDialogComponent}.
*/
editDialogComponent?: typeof PolicyEditDialogComponent;
editDialogComponent?: PolicyDialogComponent;
/**
* If true, the {@link description} will be reused in the policy edit modal. Set this to false if you

View File

@@ -1,17 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
firstValueFrom,
lastValueFrom,
Observable,
of,
switchMap,
first,
map,
withLatestFrom,
tap,
} from "rxjs";
import {
@@ -19,9 +20,11 @@ import {
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { safeProvider } from "@bitwarden/ui-common";
@@ -29,7 +32,7 @@ import { safeProvider } from "@bitwarden/ui-common";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component";
import { PolicyEditDialogComponent } from "./policy-edit-dialog.component";
import { PolicyListService } from "./policy-list.service";
import { POLICY_EDIT_REGISTER } from "./policy-register-token";
@@ -59,8 +62,18 @@ export class PoliciesComponent implements OnInit {
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private dialogService: DialogService,
private policyService: PolicyService,
protected configService: ConfigService,
) {}
) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
tap(async () => await this.load()),
takeUntilDestroyed(),
)
.subscribe();
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
@@ -127,17 +140,13 @@ export class PoliciesComponent implements OnInit {
}
async edit(policy: BasePolicyEditDefinition) {
const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent;
const dialogRef = dialogComponent.open(this.dialogService, {
const dialogComponent: PolicyDialogComponent =
policy.editDialogComponent ?? PolicyEditDialogComponent;
dialogComponent.open(this.dialogService, {
data: {
policy: policy,
organizationId: this.organizationId,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == "saved") {
await this.load();
}
}
}

View File

@@ -0,0 +1,59 @@
<ng-container [ngTemplateOutlet]="steps[step]()"></ng-container>
<ng-template #step0>
<p class="tw-mb-6">
{{ "autoConfirmPolicyEditDescription" | i18n }}
</p>
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
<a bitLink href="https://bitwarden.com/help/automatic-confirmation/" target="_blank">
{{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }}
<i class="bwi bwi-external-link bwi-fw"></i>
</a>
</li>
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
{{ "autoConfirmSingleOrgRequiredDescription" | i18n }}
</li>
<li>
<span class="tw-font-bold">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}
</li>
</ul>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "autoConfirmCheckBoxLabel" | i18n }}</bit-label>
</ng-template>
<ng-template #step1>
<div class="tw-flex tw-justify-center tw-mb-6">
<bit-icon class="tw-w-[233px]" [icon]="autoConfirmSvg"></bit-icon>
</div>
<ol>
<li>1. {{ "autoConfirmStep1" | i18n }}</li>
<li>
2. {{ "autoConfirmStep2a" | i18n }}
<strong>
{{ "autoConfirmStep2b" | i18n }}
</strong>
</li>
</ol>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core";
import { BehaviorSubject, map, Observable } from "rxjs";
import { AutoConfirmSvg } from "@bitwarden/assets/svg";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";
description = "autoConfirmDescription";
type = PolicyType.AutoConfirm;
component = AutoConfirmPolicyEditComponent;
showDescription = false;
editDialogComponent = AutoConfirmPolicyDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.AutoConfirm)
.pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation));
}
}
@Component({
templateUrl: "auto-confirm-policy.component.html",
imports: [SharedModule],
})
export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit {
protected readonly autoConfirmSvg = AutoConfirmSvg;
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private readonly extensionButton: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected step: number = 0;
protected steps = [this.policyForm, this.extensionButton];
protected singleOrgEnabled$: BehaviorSubject<boolean> = new BehaviorSubject(false);
setSingleOrgEnabled(enabled: boolean) {
this.singleOrgEnabled$.next(enabled);
}
setStep(step: number) {
this.step = step;
}
}

View File

@@ -14,3 +14,4 @@ export {
vNextOrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicyComponent,
} from "./vnext-organization-data-ownership.component";
export { AutoConfirmPolicy } from "./auto-confirm-policy.component";

View File

@@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -64,13 +64,13 @@ export class PolicyEditDialogComponent implements AfterViewInit {
});
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
private accountService: AccountService,
private policyApiService: PolicyApiServiceAbstraction,
private i18nService: I18nService,
protected accountService: AccountService,
protected policyApiService: PolicyApiServiceAbstraction,
protected i18nService: I18nService,
private cdr: ChangeDetectorRef,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<PolicyEditDialogResult>,
private toastService: ToastService,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private configService: ConfigService,
private keyService: KeyService,
) {}

View File

@@ -1,5 +1,6 @@
import { BasePolicyEditDefinition } from "./base-policy-edit.component";
import {
AutoConfirmPolicy,
DesktopAutotypeDefaultSettingPolicy,
DisableSendPolicy,
MasterPasswordPolicy,
@@ -33,4 +34,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
new DesktopAutotypeDefaultSettingPolicy(),
new AutoConfirmPolicy(),
];

View File

@@ -12,6 +12,8 @@ import { SharedModule } from "../../../shared";
import { EmergencyAccessModule } from "../emergency-access.module";
import { EmergencyAccessService } from "../services/emergency-access.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [SharedModule, EmergencyAccessModule],
templateUrl: "accept-emergency.component.html",

View File

@@ -11,18 +11,24 @@ import { RouterService } from "../../../core/router.service";
import { deepLinkGuard } from "./deep-link.guard";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class GuardedRouteTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class LockTestComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,

View File

@@ -16,6 +16,8 @@ import { BaseAcceptComponent } from "../../common/base.accept.component";
import { AcceptOrganizationInviteService } from "./accept-organization.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "accept-organization.component.html",
standalone: false,

View File

@@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-delete",
templateUrl: "recover-delete.component.html",

View File

@@ -16,6 +16,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-recover-two-factor",
templateUrl: "recover-two-factor.component.html",

View File

@@ -19,6 +19,8 @@ import { DeleteAccountDialogComponent } from "./delete-account-dialog.component"
import { ProfileComponent } from "./profile.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "account.component.html",
imports: [

View File

@@ -32,6 +32,8 @@ type ChangeAvatarDialogData = {
profile: ProfileResponse;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "change-avatar-dialog.component.html",
encapsulation: ViewEncapsulation.None,
@@ -40,6 +42,8 @@ type ChangeAvatarDialogData = {
export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
profile: ProfileResponse;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("colorPicker") colorPickerElement: ElementRef<HTMLElement>;
loading = false;

View File

@@ -17,6 +17,8 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-change-email",
templateUrl: "change-email.component.html",

View File

@@ -9,6 +9,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
/**
* Component for the Danger Zone section of the Account/Organization Settings page.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-danger-zone",
templateUrl: "danger-zone.component.html",

View File

@@ -12,6 +12,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deauthorize-sessions.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -12,6 +12,8 @@ import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-account-dialog.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -23,6 +23,8 @@ import { AccountFingerprintComponent } from "../../../shared/components/account-
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-profile",
templateUrl: "profile.component.html",

View File

@@ -5,6 +5,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AvatarModule } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "selectable-avatar",
template: `<span
@@ -30,12 +32,26 @@ import { AvatarModule } from "@bitwarden/components";
imports: [NgClass, AvatarModule],
})
export class SelectableAvatarComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() id: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() text: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() title: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() color: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() border = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() selected = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() select = new EventEmitter<string>();
onFire() {

View File

@@ -27,6 +27,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./set-account-verify-devices-dialog.component.html",
imports: [

View File

@@ -25,6 +25,8 @@ type EmergencyAccessConfirmDialogData = {
/** user public key */
publicKey: Uint8Array;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-confirm.component.html",
imports: [SharedModule],

View File

@@ -35,6 +35,8 @@ export enum EmergencyAccessAddEditDialogResult {
Canceled = "canceled",
Deleted = "deleted",
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-add-edit.component.html",
imports: [SharedModule, PremiumBadgeComponent],

View File

@@ -42,6 +42,8 @@ import {
EmergencyAccessTakeoverDialogResultType,
} from "./takeover/emergency-access-takeover-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access.component.html",
imports: [SharedModule, HeaderModule, PremiumBadgeComponent],

View File

@@ -48,6 +48,8 @@ export type EmergencyAccessTakeoverDialogResultType =
*
* @link https://bitwarden.com/help/emergency-access/
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "auth-emergency-access-takeover-dialog",
templateUrl: "./emergency-access-takeover-dialog.component.html",
@@ -61,6 +63,8 @@ export type EmergencyAccessTakeoverDialogResultType =
],
})
export class EmergencyAccessTakeoverDialogComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(InputPasswordComponent)
inputPasswordComponent: InputPasswordComponent | undefined = undefined;

View File

@@ -14,6 +14,8 @@ import { EmergencyAccessService } from "../../../emergency-access";
import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "emergency-access-view.component.html",
providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }],

View File

@@ -35,6 +35,8 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-emergency-view-dialog",
templateUrl: "emergency-view-dialog.component.html",

View File

@@ -23,6 +23,8 @@ export type ApiKeyDialogData = {
apiKeyWarning: string;
apiKeyDescription: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "api-key.component.html",
imports: [SharedModule, UserVerificationFormInputComponent],

View File

@@ -10,6 +10,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-password-settings",
templateUrl: "password-settings.component.html",

View File

@@ -13,6 +13,8 @@ import { SharedModule } from "../../../shared";
import { ApiKeyComponent } from "./api-key.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security-keys.component.html",
imports: [SharedModule, ChangeKdfModule],

View File

@@ -5,6 +5,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "security.component.html",
imports: [SharedModule, HeaderModule],

View File

@@ -15,6 +15,8 @@ import {
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-recovery",
templateUrl: "two-factor-recovery.component.html",

View File

@@ -53,6 +53,8 @@ declare global {
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-authenticator",
templateUrl: "two-factor-setup-authenticator.component.html",
@@ -76,6 +78,8 @@ export class TwoFactorSetupAuthenticatorComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit, OnDestroy
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus = new EventEmitter<boolean>();
type = TwoFactorProviderType.Authenticator;
key: string;

View File

@@ -30,6 +30,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-duo",
templateUrl: "two-factor-setup-duo.component.html",
@@ -51,6 +53,8 @@ export class TwoFactorSetupDuoComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Duo;

View File

@@ -33,6 +33,8 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-email",
templateUrl: "two-factor-setup-email.component.html",
@@ -54,6 +56,8 @@ export class TwoFactorSetupEmailComponent
extends TwoFactorSetupMethodBaseComponent
implements OnInit
{
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onChangeStatus: EventEmitter<boolean> = new EventEmitter();
type = TwoFactorProviderType.Email;
sentEmail: string = "";

View File

@@ -17,6 +17,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
*/
@Directive({})
export abstract class TwoFactorSetupMethodBaseComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onUpdated = new EventEmitter<boolean>();
type: TwoFactorProviderType | undefined;

View File

@@ -43,6 +43,8 @@ interface Key {
removePromise: Promise<TwoFactorWebAuthnResponse> | null;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-webauthn",
templateUrl: "two-factor-setup-webauthn.component.html",

View File

@@ -44,6 +44,8 @@ interface Key {
existingKey: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup-yubikey",
templateUrl: "two-factor-setup-yubikey.component.html",

View File

@@ -45,6 +45,8 @@ import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.com
import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component";
import { TwoFactorVerifyComponent } from "./two-factor-verify.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-setup",
templateUrl: "two-factor-setup.component.html",

View File

@@ -28,6 +28,8 @@ type TwoFactorVerifyDialogData = {
organizationId: string;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-two-factor-verify",
templateUrl: "two-factor-verify.component.html",
@@ -43,6 +45,8 @@ type TwoFactorVerifyDialogData = {
export class TwoFactorVerifyComponent {
type: TwoFactorProviderType;
organizationId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAuthed = new EventEmitter<AuthResponse<TwoFactorResponse>>();
formPromise: Promise<TwoFactorResponse> | undefined;

View File

@@ -16,6 +16,8 @@ import {
ToastService,
} from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email",
templateUrl: "verify-email.component.html",
@@ -24,7 +26,11 @@ import {
export class VerifyEmailComponent {
actionPromise: Promise<unknown>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onVerified = new EventEmitter<boolean>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDismiss = new EventEmitter<void>();
constructor(

View File

@@ -32,6 +32,8 @@ type Step =
| "credentialCreationFailed"
| "credentialNaming";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "create-credential-dialog.component.html",
standalone: false,

View File

@@ -24,6 +24,8 @@ export interface DeleteCredentialDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "delete-credential-dialog.component.html",
standalone: false,

View File

@@ -21,6 +21,8 @@ export interface EnableEncryptionDialogParams {
credentialId: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "enable-encryption-dialog.component.html",
standalone: false,

View File

@@ -17,6 +17,8 @@ import { openCreateCredentialDialog } from "./create-credential-dialog/create-cr
import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component";
import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-webauthn-login-settings",
templateUrl: "webauthn-login-settings.component.html",

View File

@@ -21,6 +21,8 @@ import {
/**
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "user-verification-prompt.component.html",
standalone: false,

View File

@@ -8,6 +8,8 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a
* @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead.
* Each client specific component should eventually be converted over to use one of these new components.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-user-verification",
templateUrl: "user-verification.component.html",

View File

@@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-email-token",
templateUrl: "verify-email-token.component.html",

View File

@@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-verify-recover-delete",
templateUrl: "verify-recover-delete.component.html",

View File

@@ -1,21 +1,29 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
firstValueFrom,
from,
map,
Observable,
of,
shareReplay,
switchMap,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
DialogService,
ToastService,
SectionComponent,
BadgeModule,
TypographyModule,
DialogService,
LinkModule,
SectionComponent,
TypographyModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -69,14 +77,14 @@ export class PremiumVNextComponent {
constructor(
private accountService: AccountService,
private i18nService: I18nService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
@@ -107,6 +115,23 @@ export class PremiumVNextComponent {
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
// redirect to user subscription page if they already have premium personally
// redirect to individual vault if they already have premium from an org
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(this.destroyRef),
switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => {
if (hasPremiumPersonally) {
return from(this.navigateToSubscriptionPage());
}
if (hasPremiumFromOrg) {
return from(this.navigateToIndividualVault());
}
return of(true);
}),
)
.subscribe();
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
@@ -141,6 +166,11 @@ export class PremiumVNextComponent {
);
}
private navigateToSubscriptionPage = (): Promise<boolean> =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
private navigateToIndividualVault = (): Promise<boolean> => this.router.navigate(["/vault"]);
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);

View File

@@ -1,132 +1,153 @@
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
@if (isLoadingPrices$ | async) {
<ng-container>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
} @else {
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storagePrice$ | async | currency: "$" }} =
{{ storageCost$ | async | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total$ | async | currency: "USD $" }}/{{
"year" | i18n
}}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>
}

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