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:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
11
.github/renovate.json5
vendored
11
.github/renovate.json5
vendored
@@ -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
1
.gitignore
vendored
@@ -28,6 +28,7 @@ npm-debug.log
|
||||
# Build directories
|
||||
dist
|
||||
build
|
||||
target
|
||||
.angular/cache
|
||||
.flatpak
|
||||
.flatpak-repo
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -852,6 +852,7 @@ export class ServiceContainer {
|
||||
this.authService,
|
||||
this.stateProvider,
|
||||
this.securityStateService,
|
||||
this.kdfConfigService,
|
||||
);
|
||||
|
||||
this.totpService = new TotpService(this.sdkService);
|
||||
|
||||
93
apps/desktop/desktop_native/Cargo.lock
generated
93
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export {
|
||||
vNextOrganizationDataOwnershipPolicy,
|
||||
vNextOrganizationDataOwnershipPolicyComponent,
|
||||
} from "./vnext-organization-data-ownership.component";
|
||||
export { AutoConfirmPolicy } from "./auto-confirm-policy.component";
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ×
|
||||
{{ 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 ×
|
||||
{{ 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
Reference in New Issue
Block a user