1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-01 17:23:37 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-10-29 14:08:38 -07:00
157 changed files with 2948 additions and 729 deletions

1
.github/CODEOWNERS vendored
View File

@@ -174,6 +174,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev
apps/web/src/app/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@@ -73,7 +73,7 @@ jobs:
- name: Trigger test-all workflow in browser-interactions-testing
if: steps.changed-files.outputs.monitored == 'true'
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
with:
token: ${{ steps.app-token.outputs.token }}
repository: "bitwarden/browser-interactions-testing"

View File

@@ -588,6 +588,9 @@
"view": {
"message": "View"
},
"viewAll": {
"message": "View all"
},
"viewLogin": {
"message": "View login"
},
@@ -1028,6 +1031,18 @@
"editedItem": {
"message": "Item saved"
},
"savedWebsite": {
"message": "Saved website"
},
"savedWebsites": {
"message": "Saved websites ( $COUNT$ )",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"deleteItemConfirmation": {
"message": "Do you really want to send to the trash?"
},
@@ -1676,9 +1691,30 @@
"turnOffAutofill": {
"message": "Turn off autofill"
},
"confirmAutofill": {
"message": "Confirm autofill"
},
"confirmAutofillDesc": {
"message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site."
},
"showInlineMenuLabel": {
"message": "Show autofill suggestions on form fields"
},
"howDoesBitwardenProtectFromPhishing": {
"message": "How does Bitwarden protect your data from phishing?"
},
"currentWebsite": {
"message": "Current website"
},
"autofillAndAddWebsite": {
"message": "Autofill and add this website"
},
"autofillWithoutAdding": {
"message": "Autofill without adding"
},
"doNotAutofill": {
"message": "Do not autofill"
},
"showInlineMenuIdentitiesLabel": {
"message": "Display identities as suggestions"
},
@@ -3240,6 +3276,9 @@
"decryptionError": {
"message": "Decryption error"
},
"errorGettingAutoFillData": {
"message": "Error getting autofill data"
},
"couldNotDecryptVaultItemsBelow": {
"message": "Bitwarden could not decrypt the vault item(s) listed below."
},
@@ -4011,6 +4050,15 @@
"message": "Autofill on page load set to use default setting.",
"description": "Toast message for informing the user that autofill on page load has been set to the default setting."
},
"cannotAutofill": {
"message": "Cannot autofill"
},
"cannotAutofillExactMatch": {
"message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item."
},
"okay": {
"message": "Okay"
},
"toggleSideNavigation": {
"message": "Toggle side navigation"
},

View File

@@ -5,55 +5,5 @@
<title>Bitwarden</title>
<meta charset="utf-8" />
</head>
<body>
<div id="notification-bar-outer-wrapper" class="outer-wrapper">
<div class="logo-wrapper">
<a href="https://vault.bitwarden.com" target="_blank" id="logo-link" rel="noreferrer">
<img id="logo" alt="Bitwarden" />
</a>
</div>
<div id="content"></div>
<div class="notification-close">
<button type="button" class="neutral" id="close-button">
<svg id="close" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none">
<path
d="M14.431 13.57 8.865 8.173a.388.388 0 0 1 0-.559l5.498-5.33a.388.388 0 0 0-.005-.553.415.415 0 0 0-.572-.006l-5.498 5.33a.416.416 0 0 1-.577 0L2.196 1.72a.403.403 0 0 0-.29-.12.422.422 0 0 0-.292.115.395.395 0 0 0-.12.283.386.386 0 0 0 .125.28l5.515 5.338a.388.388 0 0 1 0 .559L1.56 13.568a.397.397 0 0 0-.12.28c0 .105.044.205.12.28a.416.416 0 0 0 .578-.001l5.574-5.395a.416.416 0 0 1 .577 0l5.567 5.395a.422.422 0 0 0 .582.005.398.398 0 0 0 .12-.282.387.387 0 0 0-.125-.281Z"
/>
</svg>
</button>
</div>
</div>
</body>
<template id="template-add">
<div class="inner-wrapper">
<div id="add-text" class="notification-body"></div>
<div class="add-change-cipher-buttons notification-actions">
<button type="button" id="never-save" class="link"></button>
<select id="select-folder"></select>
<button type="button" id="add-edit" class="secondary"></button>
<button type="button" id="add-save" class="primary"></button>
</div>
</div>
</template>
<template id="template-change">
<div class="inner-wrapper">
<div id="change-text" class="notification-body"></div>
<div class="add-change-cipher-buttons notification-actions">
<button type="button" id="change-edit" class="secondary"></button>
<button type="button" id="change-save" class="primary"></button>
</div>
</div>
</template>
<template id="template-unlock">
<div class="inner-wrapper">
<div id="unlock-text" class="notification-body"></div>
<div class="notification-actions">
<button type="button" id="unlock-vault" class="primary"></button>
</div>
</div>
</template>
<body></body>
</html>

View File

@@ -1,304 +0,0 @@
@import "../shared/styles/variables";
body {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
line-height: 16px;
font-family: $font-family-sans-serif;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
img {
margin: 0;
padding: 0;
border: 0;
}
button,
select {
font-size: $font-size-base;
font-family: $font-family-sans-serif;
}
.outer-wrapper {
display: block;
position: relative;
padding: 8px;
min-height: 42px;
border: 1px solid transparent;
border-bottom: 2px solid transparent;
border-radius: 4px;
box-sizing: border-box;
@include themify($themes) {
border-color: themed("borderColor");
border-bottom-color: themed("primaryColor");
}
&.success-event {
@include themify($themes) {
border-bottom-color: themed("successColor");
}
}
&.error-event {
@include themify($themes) {
border-bottom-color: themed("errorColor");
}
}
}
.inner-wrapper {
display: grid;
grid-template-columns: auto max-content;
}
.outer-wrapper > *,
.inner-wrapper > * {
align-self: center;
}
#logo {
width: 24px;
height: 24px;
display: block;
}
.logo-wrapper {
position: absolute;
top: 8px;
left: 10px;
overflow: hidden;
}
#close-button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-right: 10px;
padding: 0;
&:hover {
@include themify($themes) {
border-color: rgba(themed("textColor"), 0.2);
background-color: rgba(themed("textColor"), 0.2);
}
}
}
#close {
display: block;
width: 16px;
height: 16px;
> path {
@include themify($themes) {
fill: themed("textColor");
}
}
}
.notification-close {
position: absolute;
top: 6px;
right: 6px;
}
#content .inner-wrapper {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
.notification-body {
width: 100%;
padding: 4px 38px 24px 42px;
font-weight: 400;
}
.notification-actions {
display: flex;
width: 100%;
align-items: stretch;
justify-content: flex-end;
#never-save {
margin-right: auto;
padding: 0;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.5px;
}
#select-folder {
width: 125px;
margin-right: 6px;
font-size: 12px;
appearance: none;
background-repeat: no-repeat;
background-position: center right 4px;
background-size: 16px;
@include themify($themes) {
color: themed("mutedTextColor");
border-color: themed("mutedTextColor");
}
&:not([disabled]) {
display: block;
}
}
.primary,
.secondary {
font-size: 12px;
}
.secondary {
margin-right: 6px;
border-width: 1px;
}
.primary {
margin-right: 2px;
}
&.success-message,
&.error-message {
padding: 4px 36px 6px 42px;
}
}
}
button {
padding: 4px 8px;
border-radius: $border-radius;
border: 1px solid transparent;
cursor: pointer;
}
button.primary:not(.neutral) {
@include themify($themes) {
background-color: themed("primaryColor");
color: themed("textContrast");
border-color: themed("primaryColor");
}
&:hover {
@include themify($themes) {
background-color: darken(themed("primaryColor"), 1.5%);
color: darken(themed("textContrast"), 6%);
}
}
}
button.secondary:not(.neutral) {
@include themify($themes) {
background-color: themed("backgroundColor");
color: themed("mutedTextColor");
border-color: themed("mutedTextColor");
}
&:hover {
@include themify($themes) {
background-color: themed("backgroundOffsetColor");
color: darken(themed("mutedTextColor"), 6%);
}
}
}
button.link,
button.neutral {
@include themify($themes) {
background-color: transparent;
color: themed("primaryColor");
}
&:hover {
text-decoration: underline;
@include themify($themes) {
color: darken(themed("primaryColor"), 6%);
}
}
}
select {
padding: 4px 6px;
border: 1px solid #000000;
border-radius: $border-radius;
@include themify($themes) {
color: themed("textColor");
background-color: themed("inputBackgroundColor");
border-color: themed("inputBorderColor");
}
}
.success-message {
display: flex;
align-items: center;
justify-content: center;
@include themify($themes) {
color: themed("successColor");
}
svg {
margin-right: 8px;
path {
@include themify($themes) {
fill: themed("successColor");
}
}
}
}
.error-message {
@include themify($themes) {
color: themed("errorColor");
}
}
.success-event,
.error-event {
.notification-body {
display: none;
}
}
@media screen and (max-width: 768px) {
#select-folder {
display: none;
}
}
@media print {
body {
display: none;
}
}
.theme_light {
#content .inner-wrapper {
#select-folder {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+");
}
}
}
.theme_dark {
#content .inner-wrapper {
#select-folder {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4=");
}
}
}

View File

@@ -187,8 +187,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
const notificationTestId = getNotificationTestId(notificationType);
appendHeaderMessageToTitle(headerMessage);
document.body.innerHTML = "";
if (isVaultLocked) {
const notificationConfig = {
...notificationBarIframeInitData,

View File

@@ -1,10 +1,7 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import { AutofillInlineMenuButton } from "./autofill-inline-menu-button";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./button.scss");
import "./button.css";
(function () {
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillInlineMenuButton);

View File

@@ -1,5 +1,3 @@
@import "../../../../shared/styles/variables";
* {
box-sizing: border-box;
}
@@ -27,10 +25,10 @@ autofill-inline-menu-button {
border: none;
background: transparent;
cursor: pointer;
.inline-menu-button-svg-icon {
display: block;
width: 100%;
height: auto;
}
}
.inline-menu-button .inline-menu-button-svg-icon {
display: block;
width: 100%;
height: auto;
}

View File

@@ -62,6 +62,8 @@ import { initPopupClosedListener } from "../platform/services/popup-view-cache-b
import { routerTransition } from "./app-routing.animations";
import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-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-root",
styles: [],

View File

@@ -15,6 +15,8 @@ export type DesktopSyncVerificationDialogParams = {
fingerprint: 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: "desktop-sync-verification-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -17,6 +17,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { NavButton } from "../platform/popup/layout/popup-tab-navigation.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-tabs-v2",
templateUrl: "./tabs-v2.component.html",

View File

@@ -0,0 +1,68 @@
<bit-dialog>
<span bitDialogTitle>{{ "confirmAutofill" | i18n }}</span>
<div bitDialogContent>
<p bitTypography="body2">
{{ "confirmAutofillDesc" | i18n }}
</p>
@if (savedUrls.length === 1) {
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-semibold">
{{ "savedWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="savedUrls[0]">
{{ savedUrls[0] }}
</div>
</bit-callout>
}
@if (savedUrls.length > 1) {
<div class="tw-flex tw-justify-between tw-items-center tw-mt-4 tw-mb-1 tw-pt-2">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-semibold">
{{ "savedWebsites" | i18n: savedUrls.length }}
</p>
<button
*ngIf="!savedUrlsExpanded"
type="button"
bitLink
class="tw-text-sm tw-font-bold tw-cursor-pointer"
(click)="viewAllSavedUrls()"
>
{{ "viewAll" | i18n }}
</button>
</div>
<div class="tw-pt-2" [ngClass]="savedUrlsListClass">
<div class="-tw-mt-2" *ngFor="let url of savedUrls">
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="url">
{{ url }}
</div>
</bit-callout>
</div>
</div>
}
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-semibold">
{{ "currentWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="warning" icon="bwi-globe">
<div [appA11yTitle]="currentUrl" class="tw-font-mono tw-line-clamp-1 tw-break-all">
{{ currentUrl }}
</div>
</bit-callout>
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
{{ "autofillAndAddWebsite" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
{{ "autofillWithoutAdding" | i18n }}
</button>
<button
type="button"
bitLink
linkType="secondary"
(click)="close()"
class="tw-mt-2 tw-font-bold tw-text-sm tw-justify-center tw-text-center"
>
{{ "doNotAutofill" | i18n }}
</button>
</div>
</div>
</bit-dialog>

View File

@@ -0,0 +1,192 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
AutofillConfirmationDialogParams,
} from "./autofill-confirmation-dialog.component";
describe("AutofillConfirmationDialogComponent", () => {
let fixture: ComponentFixture<AutofillConfirmationDialogComponent>;
let component: AutofillConfirmationDialogComponent;
const dialogRef = {
close: jest.fn(),
} as unknown as DialogRef;
const params: AutofillConfirmationDialogParams = {
currentUrl: "https://example.com/path?q=1",
savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"],
};
beforeEach(async () => {
jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => {
if (typeof value !== "string" || !value) {
return "";
}
try {
// handle non-URL host strings gracefully
if (!value.includes("://")) {
return value;
}
return new URL(value).hostname;
} catch {
return "";
}
});
await TestBed.configureTestingModule({
imports: [AutofillConfirmationDialogComponent],
providers: [
provideNoopAnimations(),
{ provide: DIALOG_DATA, useValue: params },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DialogService, useValue: {} },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
jest.resetAllMocks();
});
it("normalizes currentUrl and savedUrls via Utils.getHostname", () => {
expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0));
// current
expect(component.currentUrl).toBe("example.com");
// saved
expect(component.savedUrls).toEqual([
"one.example.com",
"two.example.com",
"not-a-url.example",
]);
});
it("renders normalized values into the template (shallow check)", () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain("example.com");
expect(text).toContain("one.example.com");
expect(text).toContain("two.example.com");
expect(text).toContain("not-a-url.example");
});
it("emits Canceled on close()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["close"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled);
});
it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["autofillAndAddUrl"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
});
it("emits AutofilledOnly on autofillOnly()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["autofillOnly"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly);
});
it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => {
const initial = component["savedUrlsListClass"];
expect(initial).toContain("gradient");
component["viewAllSavedUrls"]();
fixture.detectChanges();
const expanded = component["savedUrlsListClass"];
expect(expanded).toBe("");
});
it("handles empty savedUrls gracefully", async () => {
const newParams: AutofillConfirmationDialogParams = {
currentUrl: "https://bitwarden.com/help",
savedUrls: [],
};
const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
const newInstance = newFixture.componentInstance;
(newInstance as any).params = newParams;
const fresh = new AutofillConfirmationDialogComponent(
newParams as any,
dialogRef,
) as AutofillConfirmationDialogComponent;
expect(fresh.savedUrls).toEqual([]);
expect(fresh.currentUrl).toBe("bitwarden.com");
});
it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => {
const localParams: AutofillConfirmationDialogParams = {
currentUrl: "https://sub.domain.tld/x",
};
const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef);
expect(local.savedUrls).toEqual([]);
expect(local.currentUrl).toBe("sub.domain.tld");
});
it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => {
(Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com");
(Utils.getHostname as jest.Mock)
.mockImplementationOnce(() => "ok.example")
.mockImplementationOnce(() => "")
.mockImplementationOnce(() => undefined as unknown as string);
const edgeParams: AutofillConfirmationDialogParams = {
currentUrl: "https://example.com",
savedUrls: ["https://ok.example", "://bad", "%%%"],
};
const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef);
expect(edge.currentUrl).toBe("example.com");
expect(edge.savedUrls).toEqual(["ok.example"]);
});
it("renders one current-url callout and N saved-url callouts", () => {
const callouts = Array.from(
fixture.nativeElement.querySelectorAll("bit-callout"),
) as HTMLElement[];
expect(callouts.length).toBe(1 + params.savedUrls!.length);
});
it("renders normalized hostnames into the DOM text", () => {
const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " ");
expect(text).toContain("example.com");
expect(text).toContain("one.example.com");
expect(text).toContain("two.example.com");
});
it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => {
const findViewAll = () =>
fixture.nativeElement.querySelector(
"button.tw-text-sm.tw-font-bold.tw-cursor-pointer",
) as HTMLButtonElement | null;
let btn = findViewAll();
expect(btn).toBeTruthy();
btn!.click();
fixture.detectChanges();
btn = findViewAll();
expect(btn).toBeFalsy();
expect(component.savedUrlsExpanded).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
ButtonModule,
DialogService,
DialogModule,
TypographyModule,
CalloutComponent,
LinkModule,
} from "@bitwarden/components";
export interface AutofillConfirmationDialogParams {
savedUrls?: string[];
currentUrl: string;
}
export const AutofillConfirmationDialogResult = Object.freeze({
AutofillAndUrlAdded: "added",
AutofilledOnly: "autofilled",
Canceled: "canceled",
} as const);
export type AutofillConfirmationDialogResultType = UnionOfValues<
typeof AutofillConfirmationDialogResult
>;
@Component({
templateUrl: "./autofill-confirmation-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ButtonModule,
CalloutComponent,
CommonModule,
DialogModule,
LinkModule,
TypographyModule,
JslibModule,
],
})
export class AutofillConfirmationDialogComponent {
AutofillConfirmationDialogResult = AutofillConfirmationDialogResult;
currentUrl: string = "";
savedUrls: string[] = [];
savedUrlsExpanded = false;
constructor(
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
private dialogRef: DialogRef,
) {
this.currentUrl = Utils.getHostname(params.currentUrl);
this.savedUrls =
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
}
protected get savedUrlsListClass(): string {
return this.savedUrlsExpanded
? ""
: `tw-relative
tw-max-h-24
tw-overflow-hidden
after:tw-pointer-events-none after:tw-content-['']
after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0
after:tw-h-8 after:tw-bg-gradient-to-t
after:tw-from-background after:tw-to-transparent
`;
}
protected viewAllSavedUrls() {
this.savedUrlsExpanded = true;
}
protected close() {
this.dialogRef.close(AutofillConfirmationDialogResult.Canceled);
}
protected autofillAndAddUrl() {
this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
}
protected autofillOnly() {
this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly);
}
static open(
dialogService: DialogService,
config: DialogConfig<AutofillConfirmationDialogParams>,
) {
return dialogService.open<AutofillConfirmationDialogResultType>(
AutofillConfirmationDialogComponent,
{ ...config },
);
}
}

View File

@@ -13,9 +13,17 @@
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
<button type="button" bitMenuItem *ngIf="canEdit && isLogin" (click)="doAutofillAndSave()">
{{ "fillAndSave" | i18n }}
</button>
<!-- Autofill confirmation handles both 'autofill' and 'autofill and save' so no need to show both -->
@if (!(showAutofillConfirmation$ | async)) {
<button
type="button"
bitMenuItem
*ngIf="canEdit && isLogin"
(click)="doAutofillAndSave()"
>
{{ "fillAndSave" | i18n }}
</button>
}
</ng-container>
</ng-container>
<ng-container *ngIf="showViewOption">

View File

@@ -0,0 +1,241 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import {
UriMatchStrategy,
UriMatchStrategySetting,
} from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component";
import { ItemMoreOptionsComponent } from "./item-more-options.component";
describe("ItemMoreOptionsComponent", () => {
let fixture: ComponentFixture<ItemMoreOptionsComponent>;
let component: ItemMoreOptionsComponent;
const dialogService = {
openSimpleDialog: jest.fn().mockResolvedValue(true),
open: jest.fn(),
};
const featureFlag$ = new BehaviorSubject<boolean>(false);
const configService = {
getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()),
};
const cipherService = {
getFullCipherView: jest.fn(),
encrypt: jest.fn(),
updateWithServer: jest.fn(),
softDeleteWithServer: jest.fn(),
};
const autofillSvc = {
doAutofill: jest.fn(),
doAutofillAndSave: jest.fn(),
currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null),
autofillAllowed$: new BehaviorSubject(true),
};
const uriMatchStrategy$ = new BehaviorSubject<UriMatchStrategySetting>(UriMatchStrategy.Domain);
const domainSettingsService = {
resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(),
};
const hasSearchText$ = new BehaviorSubject(false);
const vaultPopupItemsService = {
hasSearchText$: hasSearchText$.asObservable(),
};
const baseCipher = {
id: "cipher-1",
login: {
uris: [
{ uri: "https://one.example.com" },
{ uri: "" },
{ uri: undefined as unknown as string },
{ uri: "https://two.example.com/a" },
],
username: "user",
},
favorite: false,
reprompt: 0,
type: CipherType.Login,
viewPassword: true,
edit: true,
} as any;
beforeEach(waitForAsync(async () => {
jest.clearAllMocks();
cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c }));
TestBed.configureTestingModule({
imports: [ItemMoreOptionsComponent, NoopAnimationsModule],
providers: [
{ provide: ConfigService, useValue: configService },
{ provide: CipherService, useValue: cipherService },
{ provide: VaultPopupAutofillService, useValue: autofillSvc },
{ provide: I18nService, useValue: { t: (k: string) => k } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } },
{ provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } },
{
provide: CipherAuthorizationService,
useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) },
},
{ provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } },
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{
provide: DomainSettingsService,
useValue: domainSettingsService,
},
{
provide: VaultPopupItemsService,
useValue: vaultPopupItemsService,
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
TestBed.overrideProvider(DialogService, { useValue: dialogService });
await TestBed.compileComponents();
fixture = TestBed.createComponent(ItemMoreOptionsComponent);
component = fixture.componentInstance;
component.cipher = baseCipher;
}));
afterEach(() => {
jest.restoreAllMocks();
});
function mockConfirmDialogResult(result: string) {
const openSpy = jest
.spyOn(AutofillConfirmationDialogComponent, "open")
.mockReturnValue({ closed: of(result) } as any);
return openSpy;
}
it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
await component.doAutofill();
expect(cipherService.getFullCipherView).toHaveBeenCalled();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
expect.objectContaining({ id: "cipher-1" }),
false,
);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
featureFlag$.next(true);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
expect(openSpy).toHaveBeenCalledTimes(1);
const args = openSpy.mock.calls[0][1];
expect(args.data.currentUrl).toBe("https://page.example.com/path");
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
});
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
await component.doAutofill();
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
});
it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
featureFlag$.next(true);
uriMatchStrategy$.next(UriMatchStrategy.Exact);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
featureFlag$.next(true);
hasSearchText$.next(true);
fixture.detectChanges();
await fixture.whenStable();
const fillAndSaveButton = fixture.nativeElement.querySelector(
"button[bitMenuItem]:not([disabled])",
);
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
});
});

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
@@ -11,8 +9,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
@@ -32,7 +34,12 @@ import {
import { PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
} from "../autofill-confirmation-dialog/autofill-confirmation-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
@@ -42,7 +49,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
})
export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherViewLike>(undefined);
private _cipher$ = new BehaviorSubject<CipherViewLike>({} as CipherViewLike);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -64,7 +71,7 @@ export class ItemMoreOptionsComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
showViewOption: boolean;
showViewOption = false;
/**
* Flag to hide the autofill menu options. Used for items that are
@@ -73,10 +80,17 @@ export class ItemMoreOptionsComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions: boolean;
hideAutofillOptions = false;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
protected showAutofillConfirmation$ = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation),
this.vaultPopupItemsService.hasSearchText$,
]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText));
protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$;
/**
* Observable that emits a boolean value indicating if the user is authorized to clone the cipher.
* @protected
@@ -146,6 +160,9 @@ export class ItemMoreOptionsComponent {
private collectionService: CollectionService,
private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService,
private configService: ConfigService,
private vaultPopupItemsService: VaultPopupItemsService,
private domainSettingsService: DomainSettingsService,
) {}
get canEdit() {
@@ -177,14 +194,63 @@ export class ItemMoreOptionsComponent {
return this.cipher.favorite ? "unfavorite" : "favorite";
}
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
await this.vaultPopupAutofillService.doAutofill(cipher);
}
async doAutofillAndSave() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false);
await this.vaultPopupAutofillService.doAutofillAndSave(cipher);
}
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
if (!showAutofillConfirmation) {
await this.vaultPopupAutofillService.doAutofill(cipher, false);
return;
}
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
if (uriMatchStrategy === UriMatchStrategy.Exact) {
await this.dialogService.openSimpleDialog({
title: { key: "cannotAutofill" },
content: { key: "cannotAutofillExactMatch" },
type: "info",
acceptButtonText: { key: "okay" },
cancelButtonText: null,
});
return;
}
const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$);
if (!currentTab?.url) {
await this.dialogService.openSimpleDialog({
title: { key: "error" },
content: { key: "errorGettingAutoFillData" },
type: "danger",
});
return;
}
const ref = AutofillConfirmationDialogComponent.open(this.dialogService, {
data: {
currentUrl: currentTab?.url || "",
savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
},
});
const result = await firstValueFrom(ref.closed);
switch (result) {
case AutofillConfirmationDialogResult.Canceled:
return;
case AutofillConfirmationDialogResult.AutofilledOnly:
await this.vaultPopupAutofillService.doAutofill(cipher);
return;
case AutofillConfirmationDialogResult.AutofillAndUrlAdded:
await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false);
return;
}
}
async onView() {
@@ -204,15 +270,14 @@ export class ItemMoreOptionsComponent {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
cipher.favorite = !cipher.favorite;
const activeUserId = await firstValueFrom(
const activeUserId = (await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
)) as UserId;
const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),

View File

@@ -261,6 +261,13 @@ export class VaultPopupItemsService {
this.remainingCiphers$.pipe(map(() => false)),
).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/** Observable that indicates whether there is search text present.
*/
hasSearchText$: Observable<boolean> = this._hasSearchText.pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Observable that indicates whether a filter or search text is currently applied to the ciphers.
*/

View File

@@ -0,0 +1,141 @@
//! This file implements Polkit based system unlock.
//!
//! # Security
//! This section describes the assumed security model and security guarantees achieved. In the required security
//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space)
//! is compromised in this state.
//!
//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory,
//! protected by memfd_secret. This makes it inaccessible to other processes, even if they compromise root, a kernel compromise
//! has circumventable best-effort protections. While the app is running this key is held in memory, even if locked.
//! When unlocking, the app will prompt the user via `polkit` to get a yes/no decision on whether to release the key to the app.
use anyhow::{anyhow, Result};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, warn};
use zbus::Connection;
use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject};
use crate::secure_memory::*;
pub struct BiometricLockSystem {
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure
// locked vaults cannot be unlocked
secure_memory: Arc<Mutex<crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore>>,
}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {
secure_memory: Arc::new(Mutex::new(
crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(),
)),
}
}
}
impl Default for BiometricLockSystem {
fn default() -> Self {
Self::new()
}
}
impl super::BiometricTrait for BiometricLockSystem {
async fn authenticate(&self, _hwnd: Vec<u8>, _message: String) -> Result<bool> {
polkit_authenticate_bitwarden_policy().await
}
async fn authenticate_available(&self) -> Result<bool> {
polkit_is_bitwarden_policy_available().await
}
async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<()> {
// Not implemented
Ok(())
}
async fn provide_key(&self, user_id: &str, key: &[u8]) {
self.secure_memory
.lock()
.await
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
if !polkit_authenticate_bitwarden_policy().await? {
return Err(anyhow!("Authentication failed"));
}
self.secure_memory
.lock()
.await
.get(user_id)
.ok_or(anyhow!("No key found"))
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
Ok(self.secure_memory.lock().await.has(user_id))
}
async fn has_persistent(&self, _user_id: &str) -> Result<bool> {
Ok(false)
}
async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> {
self.secure_memory.lock().await.remove(user_id);
Ok(())
}
}
/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no custom
/// rules in the system skipping the authorization check, in which case this counts as UV / authentication.
async fn polkit_authenticate_bitwarden_policy() -> Result<bool> {
debug!("[Polkit] Authenticating / performing UV");
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
let details = std::collections::HashMap::new();
let authorization_result = proxy
.check_authorization(
&subject,
"com.bitwarden.Bitwarden.unlock",
&details,
CheckAuthorizationFlags::AllowUserInteraction.into(),
"",
)
.await;
match authorization_result {
Ok(result) => Ok(result.is_authorized),
Err(e) => {
warn!("[Polkit] Error performing authentication: {:?}", e);
Ok(false)
}
}
}
async fn polkit_is_bitwarden_policy_available() -> Result<bool> {
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let actions = proxy.enumerate_actions("en").await?;
for action in actions {
if action.action_id == "com.bitwarden.Bitwarden.unlock" {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore]
async fn test_polkit_authenticate() {
let result = polkit_authenticate_bitwarden_policy().await;
assert!(result.is_ok());
}
}

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unimplemented.rs")]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric_v2;

View File

@@ -1,7 +1,7 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
mod encrypted_memory_store;
pub(crate) mod encrypted_memory_store;
mod secure_key;
/// The secure memory store provides an ephemeral key-value store for sensitive data.

View File

@@ -67,6 +67,8 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
import { DesktopPremiumUpgradePromptService } from "../../services/desktop-premium-upgrade-prompt.service";
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.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-settings",
templateUrl: "settings.component.html",

View File

@@ -94,6 +94,8 @@ const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes
const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
// 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-root",
styles: [],
@@ -118,14 +120,26 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours
standalone: false,
})
export class AppComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("settings", { read: ViewContainerRef, static: true }) settingsRef: ViewContainerRef;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("premium", { read: ViewContainerRef, static: true }) premiumRef: ViewContainerRef;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("passwordHistory", { read: ViewContainerRef, static: true })
passwordHistoryRef: ViewContainerRef;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("exportVault", { read: ViewContainerRef, static: true })
exportVaultModalRef: ViewContainerRef;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("appGenerator", { read: ViewContainerRef, static: true })
generatorModalRef: ViewContainerRef;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("loginApproval", { read: ViewContainerRef, static: true })
loginApprovalModalRef: ViewContainerRef;

View File

@@ -5,20 +5,38 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// 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-avatar",
template: `<img *ngIf="src" [src]="src" [ngClass]="{ 'rounded-circle': circle }" />`,
standalone: false,
})
export class AvatarComponent implements OnChanges, OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() size = 45;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() charCount = 2;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() fontSize = 20;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() dynamic = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() circle = false;
// 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() id?: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() text?: string;
private svgCharCount = 2;

View File

@@ -7,6 +7,8 @@ export type BrowserSyncVerificationDialogParams = {
fingerprint: 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: "browser-sync-verification-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -11,6 +11,8 @@ import { FormFieldModule } from "@bitwarden/components";
* @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",
imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, FormsModule],

View File

@@ -7,6 +7,8 @@ export type VerifyNativeMessagingDialogData = {
applicationName: 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: "verify-native-messaging-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -31,6 +31,8 @@ type InactiveAccount = ActiveAccount & {
authenticationStatus: AuthenticationStatus;
};
// 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-account-switcher",
templateUrl: "account-switcher.component.html",

View File

@@ -1,5 +1,7 @@
import { Component } from "@angular/core";
// 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-header",
templateUrl: "header.component.html",

View File

@@ -4,6 +4,8 @@ import { RouterLink, RouterLinkActive } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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-nav",
templateUrl: "nav.component.html",

View File

@@ -8,6 +8,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { SearchBarService, SearchBarState } from "./search-bar.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-search",
templateUrl: "search.component.html",

View File

@@ -794,6 +794,9 @@ export class VaultComponent implements OnInit, OnDestroy {
case "viewEvents":
await this.viewEvents(event.item);
break;
case "editCipher":
await this.editCipher(event.item);
break;
}
} finally {
this.processingEvent$.next(false);
@@ -856,7 +859,7 @@ export class VaultComponent implements OnInit, OnDestroy {
* @param cipherView - When set, the cipher to be edited
* @param cloneCipher - `true` when the cipher should be cloned.
*/
async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) {
async editCipher(cipher: CipherView | undefined, cloneCipher?: boolean) {
if (
cipher &&
cipher.reprompt !== 0 &&

View File

@@ -1,13 +1,17 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import {
CollectionService,
OrganizationUserApiService,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { GroupApiService } from "../../../core";
@@ -18,6 +22,9 @@ describe("OrganizationMembersService", () => {
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
let groupService: jest.Mocked<GroupApiService>;
let apiService: jest.Mocked<ApiService>;
let keyService: jest.Mocked<KeyService>;
let accountService: jest.Mocked<AccountService>;
let collectionService: jest.Mocked<CollectionService>;
const mockOrganizationId = "org-123" as OrganizationId;
@@ -51,6 +58,7 @@ describe("OrganizationMembersService", () => {
const createMockCollection = (id: string, name: string) => ({
id,
name,
organizationId: mockOrganizationId,
});
beforeEach(() => {
@@ -66,12 +74,27 @@ describe("OrganizationMembersService", () => {
getCollections: jest.fn(),
} as any;
keyService = {
orgKeys$: jest.fn(),
} as any;
accountService = {
activeAccount$: of({ id: "user-123" } as any),
} as any;
collectionService = {
decryptMany$: jest.fn(),
} as any;
TestBed.configureTestingModule({
providers: [
OrganizationMembersService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: GroupApiService, useValue: groupService },
{ provide: ApiService, useValue: apiService },
{ provide: KeyService, useValue: keyService },
{ provide: AccountService, useValue: accountService },
{ provide: CollectionService, useValue: collectionService },
],
});
@@ -88,11 +111,15 @@ describe("OrganizationMembersService", () => {
data: [mockUser],
} as any;
const mockCollections = [createMockCollection("col-1", "Collection 1")];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [{ id: "col-1", name: "Collection 1" }];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -171,11 +198,19 @@ describe("OrganizationMembersService", () => {
createMockCollection("col-2", "Alpha Collection"),
createMockCollection("col-3", "Beta Collection"),
];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [
{ id: "col-1", name: "Zebra Collection" },
{ id: "col-2", name: "Alpha Collection" },
{ id: "col-3", name: "Beta Collection" },
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -223,11 +258,19 @@ describe("OrganizationMembersService", () => {
// col-2 is missing - should be filtered out
createMockCollection("col-3", "Collection 3"),
];
const mockOrgKey = { [mockOrganizationId]: {} as any };
const mockDecryptedCollections = [
{ id: "col-1", name: "Collection 1" },
// col-2 is missing - should be filtered out
{ id: "col-3", name: "Collection 3" },
];
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: mockCollections,
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any));
const result = await service.loadUsers(organization);
@@ -269,11 +312,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: null as any,
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);
@@ -285,11 +331,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: undefined as any,
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);
@@ -322,11 +371,14 @@ describe("OrganizationMembersService", () => {
const mockUsersResponse: ListResponse<OrganizationUserUserDetailsResponse> = {
data: [mockUser],
} as any;
const mockOrgKey = { [mockOrganizationId]: {} as any };
organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse);
apiService.getCollections.mockResolvedValue({
data: [],
} as any);
keyService.orgKeys$.mockReturnValue(of(mockOrgKey));
collectionService.decryptMany$.mockReturnValue(of([]));
const result = await service.loadUsers(organization);

View File

@@ -1,8 +1,18 @@
import { Injectable } from "@angular/core";
import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
Collection,
CollectionData,
CollectionDetailsResponse,
CollectionService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { KeyService } from "@bitwarden/key-management";
import { GroupApiService } from "../../../core";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@@ -13,6 +23,9 @@ export class OrganizationMembersService {
private organizationUserApiService: OrganizationUserApiService,
private groupService: GroupApiService,
private apiService: ApiService,
private keyService: KeyService,
private accountService: AccountService,
private collectionService: CollectionService,
) {}
async loadUsers(organization: Organization): Promise<OrganizationUserView[]> {
@@ -62,15 +75,38 @@ export class OrganizationMembersService {
}
private async getCollectionNameMap(organization: Organization): Promise<Map<string, string>> {
const response = this.apiService
.getCollections(organization.id)
.then((res) =>
res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })),
);
const collections$ = from(this.apiService.getCollections(organization.id)).pipe(
map((response) => {
return response.data.map((r) =>
Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)),
);
}),
);
const collections = await response;
const collectionMap = new Map<string, string>();
collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name));
return collectionMap;
const orgKey$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((orgKeys) => {
if (orgKeys == null) {
throw new Error("Organization keys not found for provided User.");
}
return orgKeys;
}),
);
return await firstValueFrom(
combineLatest([orgKey$, collections$]).pipe(
switchMap(([orgKey, collections]) =>
this.collectionService.decryptMany$(collections, orgKey),
),
map((decryptedCollections) => {
const collectionMap: Map<string, string> = new Map<string, string>();
decryptedCollections.forEach((c) => {
collectionMap.set(c.id, c.name);
});
return collectionMap;
}),
),
);
}
}

View File

@@ -33,6 +33,8 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management";
const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes
// 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-root",
templateUrl: "app.component.html",

View File

@@ -38,7 +38,7 @@
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('upgradeToFamilies' | i18n) }"
[button]="{ type: 'secondary', text: ('startFreeFamiliesTrial' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>

View File

@@ -98,7 +98,7 @@ describe("UpgradeAccountComponent", () => {
expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12);
expect(sut["familiesCardDetails"].price.cadence).toBe("monthly");
expect(sut["familiesCardDetails"].button.type).toBe("secondary");
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
expect(sut["familiesCardDetails"].button.text).toBe("startFreeFamiliesTrial");
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
});

View File

@@ -119,7 +119,7 @@ export class UpgradeAccountComponent implements OnInit {
},
button: {
text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium",
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",
),
type: buttonType,
},

View File

@@ -161,7 +161,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium",
);
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });

View File

@@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => {
describe("showSponsoredFamiliesDropdown$", () => {
it("should return true when all conditions are met", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets all criteria
@@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when organization is not Enterprise", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that is not Enterprise tier
@@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => {
expect(result).toBe(false);
});
it("should return false when feature flag is disabled", async () => {
// Configure mocks to disable feature flag
configService.getFeatureFlag$.mockReturnValue(of(false));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets other criteria
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return false when families feature is disabled by policy", async () => {
// Configure mocks with a policy that disables the feature
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(
of([{ organizationId: "org-id", enabled: true } as Policy]),
);
@@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when useAdminSponsoredFamilies is false", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization with useAdminSponsoredFamilies set to false
@@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user is an owner but not admin", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user is owner but not admin
@@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return true when user can manage users but is not admin or owner", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user can manage users but is not admin or owner
@@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => {
it("should return false when user has no admin permissions", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user has no admin permissions

View File

@@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
interface EnterpriseOrgStatus {
isFreeFamilyPolicyEnabled: boolean;
@@ -23,7 +21,6 @@ export class FreeFamiliesPolicyService {
private policyService: PolicyService,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
) {}
organizations$ = this.accountService.activeAccount$.pipe(
@@ -58,20 +55,14 @@ export class FreeFamiliesPolicyService {
userId,
);
return combineLatest([
enterpriseOrganization$,
this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships),
organization,
policies$,
]).pipe(
map(([isEnterprise, featureFlagEnabled, org, policies]) => {
return combineLatest([enterpriseOrganization$, organization, policies$]).pipe(
map(([isEnterprise, org, policies]) => {
const familiesFeatureDisabled = policies.some(
(policy) => policy.organizationId === org.id && policy.enabled,
);
return (
isEnterprise &&
featureFlagEnabled &&
!familiesFeatureDisabled &&
org.useAdminSponsoredFamilies &&
(org.isAdmin || org.isOwner || org.canManageUsers)

View File

@@ -8,6 +8,8 @@ import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.servic
import { SharedModule } from "../shared";
type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall";
// 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: "dynamic-avatar",
imports: [SharedModule],
@@ -25,10 +27,20 @@ type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall";
</span>`,
})
export class DynamicAvatarComponent implements OnDestroy {
// 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() 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() size: SizeTypes = "default";
private destroy$ = new Subject<void>();

View File

@@ -12,6 +12,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
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: "environment-selector",
templateUrl: "environment-selector.component.html",

View File

@@ -121,4 +121,153 @@ describe("InactiveTwoFactorReportComponent", () => {
it("should call fullSync method of syncService", () => {
expect(syncServiceMock.fullSync).toHaveBeenCalledWith(false);
});
describe("isInactive2faCipher", () => {
beforeEach(() => {
// Add both domain and host to services map
component.services.set("example.com", "https://example.com/2fa-doc");
component.services.set("sub.example.com", "https://sub.example.com/2fa-doc");
fixture.detectChanges();
});
it("should return true and documentation for cipher with matching domain", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://example.com/2fa-doc");
});
it("should return true and documentation for cipher with matching host", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://sub.example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://sub.example.com/2fa-doc");
});
it("should return false for cipher with non-matching domain or host", () => {
const cipher = createCipherView({
login: {
uris: [{ uri: "https://otherdomain.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher type is not Login", () => {
const cipher = createCipherView({
type: 2,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher has TOTP", () => {
const cipher = createCipherView({
login: {
totp: "some-totp",
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher is deleted", () => {
const cipher = createCipherView({
isDeleted: true,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher does not have edit access and no organization", () => {
component.organization = null;
const cipher = createCipherView({
edit: false,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should return false if cipher does not have viewPassword", () => {
const cipher = createCipherView({
viewPassword: false,
login: {
uris: [{ uri: "https://example.com/login" }],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
it("should check all uris and return true if any matches domain or host", () => {
const cipher = createCipherView({
login: {
uris: [
{ uri: "https://otherdomain.com/login" },
{ uri: "https://sub.example.com/dashboard" },
],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(true);
expect(doc).toBe("https://sub.example.com/2fa-doc");
});
it("should return false if uris array is empty", () => {
const cipher = createCipherView({
login: {
uris: [],
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
});
function createCipherView({
type = 1,
login = {},
isDeleted = false,
edit = true,
viewPassword = true,
}: any): any {
return {
id: "test-id",
type,
login: {
totp: null,
hasUris: true,
uris: [],
...login,
},
isDeleted,
edit,
viewPassword,
};
}
});
});

View File

@@ -109,7 +109,18 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
const u = login.uris[i];
if (u.uri != null && u.uri !== "") {
const uri = u.uri.replace("www.", "");
const host = Utils.getHost(uri);
const domain = Utils.getDomain(uri);
// check host first
if (host != null && this.services.has(host)) {
if (this.services.get(host) != null) {
docFor2fa = this.services.get(host) || "";
}
isInactive2faCipher = true;
break;
}
// then check domain
if (domain != null && this.services.has(domain)) {
if (this.services.get(domain) != null) {
docFor2fa = this.services.get(domain) || "";

View File

@@ -19,6 +19,8 @@ import { RequestSMAccessRequest } from "../models/requests/request-sm-access.req
import { SmLandingApiService } from "./sm-landing-api.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-request-sm-access",
templateUrl: "request-sm-access.component.html",

View File

@@ -12,6 +12,8 @@ import { NoItemsModule, SearchModule } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared/shared.module";
// 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-sm-landing",
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule],

View File

@@ -12,6 +12,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
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({
selector: "app-domain-rules",
templateUrl: "domain-rules.component.html",

View File

@@ -39,6 +39,8 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
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({
selector: "app-preferences",
templateUrl: "preferences.component.html",

View File

@@ -169,10 +169,12 @@
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
@if (!viewingOrgVault) {
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
}
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}

View File

@@ -162,7 +162,7 @@
[showPremiumFeatures]="showPremiumFeatures"
[useEvents]="useEvents"
[viewingOrgVault]="viewingOrgVault"
[cloneable]="canClone(item)"
[cloneable]="canClone$(item) | async"
[organizations]="allOrganizations"
[collections]="allCollections"
[checked]="selection.isSelected(item)"

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
@@ -111,8 +111,6 @@ export class VaultItemsComponent<C extends CipherViewLike> {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() enforceOrgDataOwnershipPolicy: boolean;
private readonly restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
private _ciphers?: C[] = [];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@@ -390,37 +388,22 @@ export class VaultItemsComponent<C extends CipherViewLike> {
});
}
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem<C>) {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = this.restrictedPolicies().some(
(rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher),
protected canClone$(vaultItem: VaultItem<C>): Observable<boolean> {
return this.restrictedItemTypesService.restricted$.pipe(
switchMap((restrictedTypes) => {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = restrictedTypes.some(
(rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher),
);
if (isItemRestricted) {
return of(false);
}
return this.cipherAuthorizationService.canCloneCipher$(
vaultItem.cipher,
this.showAdminActions,
);
}),
);
if (isItemRestricted) {
return false;
}
if (vaultItem.cipher.organizationId == null) {
return true;
}
const org = this.allOrganizations.find((o) => o.id === vaultItem.cipher.organizationId);
// Admins and custom users can always clone in the Org Vault
if (this.viewingOrgVault && (org.isAdmin || org.permissions.editAnyCollection)) {
return true;
}
// Check if the cipher belongs to a collection with canManage permission
const orgCollections = this.allCollections.filter((c) => c.organizationId === org.id);
for (const collection of orgCollections) {
if (vaultItem.cipher.collectionIds.includes(collection.id as any) && collection.manage) {
return true;
}
}
return false;
}
protected canEditCipher(cipher: C) {

View File

@@ -6495,17 +6495,32 @@
"tdeDisabledMasterPasswordRequired": {
"message": "Your organization has updated your decryption options. Please set a master password to access your vault."
},
"maximumVaultTimeout": {
"message": "Vault timeout"
"sessionTimeoutPolicyTitle": {
"message": "Session timeout"
},
"maximumVaultTimeoutDesc": {
"message": "Set a maximum vault timeout for members."
"sessionTimeoutPolicyDescription": {
"message": "Set a maximum session timeout for all members except owners."
},
"maximumVaultTimeoutLabel": {
"message": "Maximum vault timeout"
"maximumAllowedTimeout": {
"message": "Maximum allowed timeout"
},
"invalidMaximumVaultTimeout": {
"message": "Invalid maximum vault timeout."
"maximumAllowedTimeoutRequired": {
"message": "Maximum allowed timeout is required."
},
"sessionTimeoutPolicyInvalidTime": {
"message": "Time is invalid. Change at least one value."
},
"sessionTimeoutAction": {
"message": "Session timeout action"
},
"immediately": {
"message": "Immediately"
},
"onSystemLock": {
"message": "On system lock"
},
"onAppRestart": {
"message": "On app restart"
},
"hours": {
"message": "Hours"
@@ -6513,6 +6528,21 @@
"minutes": {
"message": "Minutes"
},
"sessionTimeoutConfirmationNeverTitle": {
"message": "Are you certain you want to allow a maximum timeout of \"Never\" for all members?"
},
"sessionTimeoutConfirmationNeverDescription": {
"message": "This option will save your members' encryption keys on their devices. If you choose this option, ensure that their devices are adequately protected."
},
"learnMoreAboutDeviceProtection": {
"message": "Learn more about device protection"
},
"sessionTimeoutConfirmationOnSystemLockTitle": {
"message": "\"System lock\" will only apply to the browser and desktop app"
},
"sessionTimeoutConfirmationOnSystemLockDescription": {
"message": "The mobile and web app will use \"on app restart\" as their maximum allowed timeout, since the option is not supported."
},
"vaultTimeoutPolicyInEffect": {
"message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).",
"placeholders": {
@@ -11945,5 +11975,8 @@
},
"cardNumberLabel": {
"message": "Card number"
},
"startFreeFamiliesTrial": {
"message": "Start free Families trial"
}
}

View File

@@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core";
import { AppComponent as BaseAppComponent } from "@bitwarden/browser/popup/app.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-root",
templateUrl: "../../../../apps/browser/src/popup/app.component.html",

View File

@@ -165,6 +165,7 @@ export class RiskInsightsOrchestratorService {
initializeForOrganization(organizationId: OrganizationId) {
this.logService.debug("[RiskInsightsOrchestratorService] Initializing for org", organizationId);
this._initializeOrganizationTriggerSubject.next(organizationId);
this.fetchReport();
}
removeCriticalApplication$(criticalApplication: string): Observable<ReportState> {
@@ -587,7 +588,7 @@ export class RiskInsightsOrchestratorService {
private _setupEnrichedReportData() {
// Setup the enriched report data pipeline
const enrichmentSubscription = combineLatest([
this.rawReportData$.pipe(filter((data) => !!data && !!data?.data)),
this.rawReportData$,
this._ciphers$.pipe(filter((data) => !!data)),
]).pipe(
switchMap(([rawReportData, ciphers]) => {
@@ -627,7 +628,7 @@ export class RiskInsightsOrchestratorService {
.pipe(
withLatestFrom(this._userId$),
filter(([orgId, userId]) => !!orgId && !!userId),
exhaustMap(([orgId, userId]) =>
switchMap(([orgId, userId]) =>
this.organizationService.organizations$(userId!).pipe(
getOrganizationById(orgId),
map((org) => ({ organizationId: orgId!, organizationName: org?.name ?? "" })),
@@ -725,7 +726,7 @@ export class RiskInsightsOrchestratorService {
scan((prevState: ReportState, currState: ReportState) => ({
...prevState,
...currState,
data: currState.data !== null ? currState.data : prevState.data,
data: currState.data,
})),
startWith({ loading: false, error: null, data: null }),
shareReplay({ bufferSize: 1, refCount: true }),

View File

@@ -1,4 +1,3 @@
export { ActivateAutofillPolicy } from "./activate-autofill.component";
export { AutomaticAppLoginPolicy } from "./automatic-app-login.component";
export { DisablePersonalVaultExportPolicy } from "./disable-personal-vault-export.component";
export { MaximumVaultTimeoutPolicy } from "./maximum-vault-timeout.component";

View File

@@ -1,32 +0,0 @@
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6 !tw-mb-0">
<bit-label>{{ "maximumVaultTimeoutLabel" | i18n }}</bit-label>
<input bitInput type="number" min="0" formControlName="hours" />
<bit-hint>{{ "hours" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end !tw-mb-0">
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
<bit-hint>{{ "minutes" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "vaultTimeoutAction" | i18n }}</bit-label>
<bit-select formControlName="action">
<bit-option
*ngFor="let option of vaultTimeoutActionOptions"
[value]="option.value"
[label]="option.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</div>

View File

@@ -1,79 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormBuilder, FormControl } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
export class MaximumVaultTimeoutPolicy extends BasePolicyEditDefinition {
name = "maximumVaultTimeout";
description = "maximumVaultTimeoutDesc";
type = PolicyType.MaximumVaultTimeout;
component = MaximumVaultTimeoutPolicyComponent;
}
// 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: "maximum-vault-timeout.component.html",
imports: [SharedModule],
})
export class MaximumVaultTimeoutPolicyComponent extends BasePolicyEditComponent {
vaultTimeoutActionOptions: { name: string; value: string }[];
data = this.formBuilder.group({
hours: new FormControl<number>(null),
minutes: new FormControl<number>(null),
action: new FormControl<string>(null),
});
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {
super();
this.vaultTimeoutActionOptions = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t(VaultTimeoutAction.Lock), value: VaultTimeoutAction.Lock },
{ name: i18nService.t(VaultTimeoutAction.LogOut), value: VaultTimeoutAction.LogOut },
];
}
protected loadData() {
const minutes = this.policyResponse.data?.minutes;
const action = this.policyResponse.data?.action;
this.data.patchValue({
hours: minutes ? Math.floor(minutes / 60) : null,
minutes: minutes ? minutes % 60 : null,
action: action,
});
}
protected buildRequestData() {
if (this.data.value.hours == null && this.data.value.minutes == null) {
return null;
}
return {
minutes: this.data.value.hours * 60 + this.data.value.minutes,
action: this.data.value.action,
};
}
async buildRequest(): Promise<PolicyRequest> {
const request = await super.buildRequest();
if (request.data?.minutes == null || request.data?.minutes <= 0) {
throw new Error(this.i18nService.t("invalidMaximumVaultTimeout"));
}
return request;
}
}

View File

@@ -4,12 +4,12 @@ import {
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { FreeFamiliesSponsorshipPolicy } from "../../billing/policies/free-families-sponsorship.component";
import { SessionTimeoutPolicy } from "../../key-management/policies/session-timeout.component";
import {
ActivateAutofillPolicy,
AutomaticAppLoginPolicy,
DisablePersonalVaultExportPolicy,
MaximumVaultTimeoutPolicy,
} from "./policy-edit-definitions";
/**
@@ -18,7 +18,7 @@ import {
* It will not appear in the web vault when running in OSS mode.
*/
const policyEditRegister: BasePolicyEditDefinition[] = [
new MaximumVaultTimeoutPolicy(),
new SessionTimeoutPolicy(),
new DisablePersonalVaultExportPolicy(),
new FreeFamiliesSponsorshipPolicy(),
new ActivateAutofillPolicy(),

View File

@@ -2,6 +2,8 @@ import { Component } from "@angular/core";
import { AppComponent as BaseAppComponent } from "@bitwarden/web-vault/app/app.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-root",
templateUrl: "../../../../apps/web/src/app/app.component.html",

View File

@@ -39,11 +39,15 @@ export class ActivityCardComponent {
/**
* The text to display for the action link
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() actionText: string = "";
/**
* Show action link
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() showActionLink: boolean = false;
/**
@@ -78,6 +82,8 @@ export class ActivityCardComponent {
/**
* Event emitted when action link is clicked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() actionClick = new EventEmitter<void>();
constructor(private router: Router) {}

View File

@@ -8,12 +8,15 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService, SearchModule, TableDataSource } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { CoreOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/core";
import {
@@ -41,7 +44,7 @@ import { MemberAccessReportView } from "./view/member-access-report.view";
safeProvider({
provide: MemberAccessReportServiceAbstraction,
useClass: MemberAccessReportService,
deps: [MemberAccessReportApiService, I18nService],
deps: [MemberAccessReportApiService, I18nService, EncryptService, KeyService, AccountService],
}),
],
})

View File

@@ -1,7 +1,13 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { MemberAccessReportApiService } from "./member-access-report-api.service";
import {
@@ -9,9 +15,14 @@ import {
memberAccessWithoutAccessDetailsReportsMock,
} from "./member-access-report.mock";
import { MemberAccessReportService } from "./member-access-report.service";
describe("ImportService", () => {
const mockOrganizationId = "mockOrgId" as OrganizationId;
const reportApiService = mock<MemberAccessReportApiService>();
const mockEncryptService = mock<EncryptService>();
const userId = newGuid() as UserId;
const mockAccountService = mockAccountServiceWith(userId);
const mockKeyService = mock<KeyService>();
let memberAccessReportService: MemberAccessReportService;
const i18nMock = mock<I18nService>({
t(key) {
@@ -20,10 +31,19 @@ describe("ImportService", () => {
});
beforeEach(() => {
mockKeyService.orgKeys$.mockReturnValue(
of({ mockOrgId: new SymmetricCryptoKey(new Uint8Array(64)) }),
);
reportApiService.getMemberAccessData.mockImplementation(() =>
Promise.resolve(memberAccessReportsMock),
);
memberAccessReportService = new MemberAccessReportService(reportApiService, i18nMock);
memberAccessReportService = new MemberAccessReportService(
reportApiService,
i18nMock,
mockEncryptService,
mockKeyService,
mockAccountService,
);
});
describe("generateMemberAccessReportView", () => {

View File

@@ -1,11 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Guid, OrganizationId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import {
getPermissionList,
convertToPermission,
@@ -22,6 +27,9 @@ export class MemberAccessReportService {
constructor(
private reportApiService: MemberAccessReportApiService,
private i18nService: I18nService,
private encryptService: EncryptService,
private keyService: KeyService,
private accountService: AccountService,
) {}
/**
* Transforms user data into a MemberAccessReportView.
@@ -78,14 +86,22 @@ export class MemberAccessReportService {
async generateUserReportExportItems(
organizationId: OrganizationId,
): Promise<MemberAccessExportItem[]> {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const organizationSymmetricKey = await firstValueFrom(
this.keyService.orgKeys$(activeUserId).pipe(map((keys) => keys[organizationId])),
);
const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId);
const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString);
const collectionNameMap = new Map(collectionNames.map((col) => [col, ""]));
for await (const key of collectionNameMap.keys()) {
const decrypted = new EncString(key);
await decrypted.decrypt(organizationId);
collectionNameMap.set(key, decrypted.decryptedValue);
const encryptedCollectionName = new EncString(key);
const collectionName = await this.encryptService.decryptString(
encryptedCollectionName,
organizationSymmetricKey,
);
collectionNameMap.set(key, collectionName);
}
const exportItems = memberAccessReports.map((report) => {

View File

@@ -0,0 +1,38 @@
<bit-dialog dialogSize="small">
<div bitDialogTitle class="tw-mt-4 tw-flex tw-flex-col tw-gap-2 tw-text-center">
<i class="bwi bwi-exclamation-triangle tw-text-3xl tw-text-warning" aria-hidden="true"></i>
<h1
bitTypography="h3"
class="tw-break-words tw-hyphens-auto tw-whitespace-normal tw-max-w-fit tw-inline-block"
>
{{ "sessionTimeoutConfirmationNeverTitle" | i18n }}
</h1>
</div>
<span
bitDialogContent
class="tw-flex tw-flex-col tw-gap-2 tw-items-center tw-text-center tw-text-base tw-break-words tw-hyphens-auto"
>
<p>{{ "sessionTimeoutConfirmationNeverDescription" | i18n }}</p>
<a
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutDeviceProtection' | i18n }}"
href="https://bitwarden.com/help/vault-timeout/"
bitLink
class="tw-flex tw-flex-row tw-gap-1"
>
{{ "learnMoreAboutDeviceProtection" | i18n }}
<i class="bwi bwi-external-link" aria-hidden="true"></i>
</a>
</span>
<div bitDialogFooter class="tw-flex tw-flex-col tw-flex-grow tw-gap-2">
<button bitButton buttonType="primary" type="button" (click)="dialogRef.close(true)">
{{ "yes" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" (click)="dialogRef.close(false)">
{{ "no" | i18n }}
</button>
</div>
</bit-dialog>

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
describe("SessionTimeoutConfirmationNeverComponent", () => {
let component: SessionTimeoutConfirmationNeverComponent;
let fixture: ComponentFixture<SessionTimeoutConfirmationNeverComponent>;
let mockDialogRef: jest.Mocked<DialogRef>;
const mockI18nService = mock<I18nService>();
const mockDialogService = mock<DialogService>();
beforeEach(async () => {
mockDialogRef = mock<DialogRef>();
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
await TestBed.configureTestingModule({
imports: [SessionTimeoutConfirmationNeverComponent, NoopAnimationsModule],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: I18nService, useValue: mockI18nService },
],
}).compileComponents();
fixture = TestBed.createComponent(SessionTimeoutConfirmationNeverComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("open", () => {
it("should call dialogService.open with correct parameters", () => {
const mockResult = mock<DialogRef>();
mockDialogService.open.mockReturnValue(mockResult);
const result = SessionTimeoutConfirmationNeverComponent.open(mockDialogService);
expect(mockDialogService.open).toHaveBeenCalledWith(
SessionTimeoutConfirmationNeverComponent,
{
disableClose: true,
},
);
expect(result).toBe(mockResult);
});
});
describe("button clicks", () => {
it("should close dialog with true when Yes button is clicked", () => {
const yesButton = fixture.nativeElement.querySelector(
'button[buttonType="primary"]',
) as HTMLButtonElement;
yesButton.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(true);
expect(yesButton.textContent?.trim()).toBe("yes-used-i18n");
});
it("should close dialog with false when No button is clicked", () => {
const noButton = fixture.nativeElement.querySelector(
'button[buttonType="secondary"]',
) as HTMLButtonElement;
noButton.click();
expect(mockDialogRef.close).toHaveBeenCalledWith(false);
expect(noButton.textContent?.trim()).toBe("no-used-i18n");
});
});
});

View File

@@ -0,0 +1,20 @@
import { Component } from "@angular/core";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/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({
imports: [SharedModule],
templateUrl: "./session-timeout-confirmation-never.component.html",
})
export class SessionTimeoutConfirmationNeverComponent {
constructor(public dialogRef: DialogRef) {}
static open(dialogService: DialogService) {
return dialogService.open<boolean>(SessionTimeoutConfirmationNeverComponent, {
disableClose: true,
});
}
}

View File

@@ -0,0 +1,39 @@
<bit-callout title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</bit-callout>
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-12 !tw-mb-0">
<bit-label>{{ "maximumAllowedTimeout" | i18n }}</bit-label>
<bit-select formControlName="type">
@for (option of typeOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
</bit-form-field>
@if (data.value.type === "custom") {
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
<bit-label>{{ "hours" | i18n }}</bit-label>
<input bitInput type="number" min="0" formControlName="hours" />
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-start !tw-mb-0">
<bit-label>{{ "minutes" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="59" formControlName="minutes" />
</bit-form-field>
}
<bit-form-field class="tw-col-span-12">
<bit-label>{{ "sessionTimeoutAction" | i18n }}</bit-label>
<bit-select formControlName="action">
@for (option of actionOptions; track option.value) {
<bit-option [value]="option.value" [label]="option.name"></bit-option>
}
</bit-select>
</bit-form-field>
</div>
</div>

View File

@@ -0,0 +1,441 @@
import { DialogCloseOptions } from "@angular/cdk/dialog";
import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
import {
SessionTimeoutAction,
SessionTimeoutPolicyComponent,
SessionTimeoutType,
} from "./session-timeout.component";
// Mock DialogRef, so we can mock "readonly closed" property.
class MockDialogRef extends DialogRef {
close(result: unknown | undefined, options: DialogCloseOptions | undefined): void {}
closed: Observable<unknown | undefined> = of();
componentInstance: unknown | null;
disableClose: boolean | undefined;
isDrawer: boolean = false;
}
describe("SessionTimeoutPolicyComponent", () => {
let component: SessionTimeoutPolicyComponent;
let fixture: ComponentFixture<SessionTimeoutPolicyComponent>;
const mockI18nService = mock<I18nService>();
const mockDialogService = mock<DialogService>();
const mockDialogRef = mock<MockDialogRef>();
beforeEach(async () => {
jest.resetAllMocks();
mockDialogRef.closed = of(true);
mockDialogService.open.mockReturnValue(mockDialogRef);
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
const testBed = TestBed.configureTestingModule({
imports: [SessionTimeoutPolicyComponent, ReactiveFormsModule],
providers: [FormBuilder, { provide: I18nService, useValue: mockI18nService }],
});
// Override DialogService provided from SharedModule (which includes DialogModule)
testBed.overrideProvider(DialogService, { useValue: mockDialogService });
await testBed.compileComponents();
fixture = TestBed.createComponent(SessionTimeoutPolicyComponent);
component = fixture.componentInstance;
});
function assertHoursAndMinutesInputsNotVisible() {
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
expect(hoursInput).toBeFalsy();
expect(minutesInput).toBeFalsy();
}
function assertHoursAndMinutesInputs(expectedHours: string, expectedMinutes: string) {
const hoursInput = fixture.nativeElement.querySelector('input[formControlName="hours"]');
const minutesInput = fixture.nativeElement.querySelector('input[formControlName="minutes"]');
expect(hoursInput).toBeTruthy();
expect(minutesInput).toBeTruthy();
expect(hoursInput.disabled).toBe(false);
expect(minutesInput.disabled).toBe(false);
expect(hoursInput.value).toBe(expectedHours);
expect(minutesInput.value).toBe(expectedMinutes);
}
function setPolicyResponseType(type: SessionTimeoutType) {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 480,
action: null,
},
});
}
describe("initialization and data loading", () => {
function assertTypeAndActionSelectElementsVisible() {
// Type and action selects should always be present
const typeSelectDebug: DebugElement = fixture.debugElement.query(
By.css('bit-select[formControlName="type"]'),
);
const actionSelectDebug: DebugElement = fixture.debugElement.query(
By.css('bit-select[formControlName="action"]'),
);
expect(typeSelectDebug).toBeTruthy();
expect(actionSelectDebug).toBeTruthy();
}
it("should initialize with default state when policy have no value", () => {
component.policyResponse = undefined;
fixture.detectChanges();
expect(component.data.controls.type.value).toBeNull();
expect(component.data.controls.type.hasError("required")).toBe(true);
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.value).toBe(0);
expect(component.data.controls.minutes.disabled).toBe(true);
expect(component.data.controls.action.value).toBeNull();
assertTypeAndActionSelectElementsVisible();
assertHoursAndMinutesInputsNotVisible();
});
// This is for backward compatibility when type field did not exist
it("should load as custom type when type field does not exist but minutes does", () => {
component.policyResponse = new PolicyResponse({
Data: {
minutes: 500,
action: VaultTimeoutAction.Lock,
},
});
fixture.detectChanges();
expect(component.data.controls.type.value).toBe("custom");
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.value).toBe(20);
expect(component.data.controls.minutes.disabled).toBe(false);
expect(component.data.controls.action.value).toBe(VaultTimeoutAction.Lock);
assertTypeAndActionSelectElementsVisible();
assertHoursAndMinutesInputs("8", "20");
});
it.each([
["never", null],
["never", VaultTimeoutAction.Lock],
["never", VaultTimeoutAction.LogOut],
["onAppRestart", null],
["onAppRestart", VaultTimeoutAction.Lock],
["onAppRestart", VaultTimeoutAction.LogOut],
["onSystemLock", null],
["onSystemLock", VaultTimeoutAction.Lock],
["onSystemLock", VaultTimeoutAction.LogOut],
["immediately", null],
["immediately", VaultTimeoutAction.Lock],
["immediately", VaultTimeoutAction.LogOut],
["custom", null],
["custom", VaultTimeoutAction.Lock],
["custom", VaultTimeoutAction.LogOut],
])("should load correctly when policy type is %s and action is %s", (type, action) => {
component.policyResponse = new PolicyResponse({
Data: {
type,
minutes: 510,
action,
},
});
fixture.detectChanges();
expect(component.data.controls.type.value).toBe(type);
expect(component.data.controls.action.value).toBe(action);
assertTypeAndActionSelectElementsVisible();
if (type === "custom") {
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(30);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
assertHoursAndMinutesInputs("8", "30");
} else {
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
assertHoursAndMinutesInputsNotVisible();
}
});
it("should have all type options and update form control when value changes", fakeAsync(() => {
expect(component.typeOptions.length).toBe(5);
expect(component.typeOptions[0].value).toBe("immediately");
expect(component.typeOptions[1].value).toBe("custom");
expect(component.typeOptions[2].value).toBe("onSystemLock");
expect(component.typeOptions[3].value).toBe("onAppRestart");
expect(component.typeOptions[4].value).toBe("never");
}));
it("should have all action options and update form control when value changes", () => {
expect(component.actionOptions.length).toBe(3);
expect(component.actionOptions[0].value).toBeNull();
expect(component.actionOptions[1].value).toBe(VaultTimeoutAction.Lock);
expect(component.actionOptions[2].value).toBe(VaultTimeoutAction.LogOut);
});
});
describe("form controls change detection", () => {
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
"should disable hours and minutes inputs when type changes from custom to %s",
fakeAsync((newType: SessionTimeoutType) => {
setPolicyResponseType("custom");
fixture.detectChanges();
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(0);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
component.data.patchValue({ type: newType });
tick();
fixture.detectChanges();
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
assertHoursAndMinutesInputsNotVisible();
}),
);
it.each(["never", "onAppRestart", "onSystemLock", "immediately"])(
"should enable hours and minutes inputs when type changes from %s to custom",
fakeAsync((oldType: SessionTimeoutType) => {
setPolicyResponseType(oldType);
fixture.detectChanges();
expect(component.data.controls.hours.disabled).toBe(true);
expect(component.data.controls.minutes.disabled).toBe(true);
component.data.patchValue({ type: "custom", hours: 8, minutes: 1 });
tick();
fixture.detectChanges();
expect(component.data.controls.hours.value).toBe(8);
expect(component.data.controls.minutes.value).toBe(1);
expect(component.data.controls.hours.disabled).toBe(false);
expect(component.data.controls.minutes.disabled).toBe(false);
assertHoursAndMinutesInputs("8", "1");
}),
);
it.each(["custom", "onAppRestart", "immediately"])(
"should not show confirmation dialog when changing to %s type",
fakeAsync((newType: SessionTimeoutType) => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: newType });
tick();
fixture.detectChanges();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
}),
);
it("should show never confirmation dialog when changing to never type", fakeAsync(() => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(mockDialogService.open).toHaveBeenCalledWith(
SessionTimeoutConfirmationNeverComponent,
{
disableClose: true,
},
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
}));
it("should show simple confirmation dialog when changing to onSystemLock type", fakeAsync(() => {
setPolicyResponseType(null);
fixture.detectChanges();
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
type: "info",
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("onSystemLock");
}));
it("should revert to previous type when type changed to never and dialog not confirmed", fakeAsync(() => {
mockDialogRef.closed = of(false);
setPolicyResponseType("immediately");
fixture.detectChanges();
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(mockDialogService.open).toHaveBeenCalled();
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("immediately");
}));
it("should revert to previous type when type changed to onSystemLock and dialog not confirmed", fakeAsync(() => {
mockDialogService.openSimpleDialog.mockResolvedValue(false);
setPolicyResponseType("immediately");
fixture.detectChanges();
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
expect(mockDialogService.openSimpleDialog).toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(component.data.controls.type.value).toBe("immediately");
}));
it("should revert to last confirmed type when canceling multiple times", fakeAsync(() => {
mockDialogRef.closed = of(false);
mockDialogService.openSimpleDialog.mockResolvedValue(false);
setPolicyResponseType("custom");
fixture.detectChanges();
// First attempt: custom -> never (cancel)
component.data.patchValue({ type: "never" });
tick();
fixture.detectChanges();
expect(component.data.controls.type.value).toBe("custom");
// Second attempt: custom -> onSystemLock (cancel)
component.data.patchValue({ type: "onSystemLock" });
tick();
fixture.detectChanges();
// Should revert to "custom", not "never"
expect(component.data.controls.type.value).toBe("custom");
}));
});
describe("buildRequestData", () => {
beforeEach(() => {
setPolicyResponseType("custom");
fixture.detectChanges();
});
it("should throw max allowed timeout required error when type is invalid", () => {
component.data.patchValue({ type: null });
expect(() => component["buildRequestData"]()).toThrow(
"maximumAllowedTimeoutRequired-used-i18n",
);
});
it.each([
[null, null],
[null, 0],
[0, null],
[0, 0],
])(
"should throw invalid time error when type is custom, hours is %o and minutes is %o ",
(hours, minutes) => {
component.data.patchValue({
type: "custom",
hours: hours,
minutes: minutes,
});
expect(() => component["buildRequestData"]()).toThrow(
"sessionTimeoutPolicyInvalidTime-used-i18n",
);
},
);
it("should return correct data when type is custom with valid time", () => {
component.data.patchValue({
type: "custom",
hours: 8,
minutes: 30,
action: VaultTimeoutAction.Lock,
});
const result = component["buildRequestData"]();
expect(result).toEqual({
type: "custom",
minutes: 510,
action: VaultTimeoutAction.Lock,
});
});
it.each([
["never", null],
["never", VaultTimeoutAction.Lock],
["never", VaultTimeoutAction.LogOut],
["immediately", null],
["immediately", VaultTimeoutAction.Lock],
["immediately", VaultTimeoutAction.LogOut],
["onSystemLock", null],
["onSystemLock", VaultTimeoutAction.Lock],
["onSystemLock", VaultTimeoutAction.LogOut],
["onAppRestart", null],
["onAppRestart", VaultTimeoutAction.Lock],
["onAppRestart", VaultTimeoutAction.LogOut],
])(
"should return default 8 hours for backward compatibility when type is %s and action is %s",
(type, action) => {
component.data.patchValue({
type: type as SessionTimeoutType,
hours: 5,
minutes: 25,
action: action as SessionTimeoutAction,
});
const result = component["buildRequestData"]();
expect(result).toEqual({
type,
minutes: 480,
action,
});
},
);
});
});

View File

@@ -0,0 +1,199 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import {
BehaviorSubject,
concatMap,
firstValueFrom,
Subject,
takeUntil,
withLatestFrom,
} from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import {
BasePolicyEditDefinition,
BasePolicyEditComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SessionTimeoutConfirmationNeverComponent } from "./session-timeout-confirmation-never.component";
export type SessionTimeoutAction = null | "lock" | "logOut";
export type SessionTimeoutType =
| null
| "never"
| "onAppRestart"
| "onSystemLock"
| "immediately"
| "custom";
export class SessionTimeoutPolicy extends BasePolicyEditDefinition {
name = "sessionTimeoutPolicyTitle";
description = "sessionTimeoutPolicyDescription";
type = PolicyType.MaximumVaultTimeout;
component = SessionTimeoutPolicyComponent;
}
const DEFAULT_HOURS = 8;
const DEFAULT_MINUTES = 0;
// 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: "session-timeout.component.html",
imports: [SharedModule],
})
export class SessionTimeoutPolicyComponent
extends BasePolicyEditComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
private lastConfirmedType$ = new BehaviorSubject<SessionTimeoutType>(null);
actionOptions: { name: string; value: SessionTimeoutAction }[];
typeOptions: { name: string; value: SessionTimeoutType }[];
data = this.formBuilder.group({
type: new FormControl<SessionTimeoutType>(null, [Validators.required]),
hours: new FormControl<number>(
{
value: DEFAULT_HOURS,
disabled: true,
},
[Validators.required],
),
minutes: new FormControl<number>(
{
value: DEFAULT_MINUTES,
disabled: true,
},
[Validators.required],
),
action: new FormControl<SessionTimeoutAction>(null),
});
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,
private dialogService: DialogService,
) {
super();
this.actionOptions = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t("lock"), value: VaultTimeoutAction.Lock },
{ name: i18nService.t("logOut"), value: VaultTimeoutAction.LogOut },
];
this.typeOptions = [
{ name: i18nService.t("immediately"), value: "immediately" },
{ name: i18nService.t("custom"), value: "custom" },
{ name: i18nService.t("onSystemLock"), value: "onSystemLock" },
{ name: i18nService.t("onAppRestart"), value: "onAppRestart" },
{ name: i18nService.t("never"), value: "never" },
];
}
ngOnInit() {
super.ngOnInit();
const typeControl = this.data.controls.type;
this.lastConfirmedType$.next(typeControl.value ?? null);
typeControl.valueChanges
.pipe(
withLatestFrom(this.lastConfirmedType$),
concatMap(async ([newType, lastConfirmedType]) => {
const confirmed = await this.confirmTypeChange(newType);
if (confirmed) {
this.updateFormControls(newType);
this.lastConfirmedType$.next(newType);
} else {
typeControl.setValue(lastConfirmedType, { emitEvent: false });
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected override loadData() {
const minutes: number | null = this.policyResponse?.data?.minutes ?? null;
const action: SessionTimeoutAction =
this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction);
// For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes"
const type: SessionTimeoutType =
this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType);
this.updateFormControls(type);
this.data.patchValue({
type: type,
hours: minutes ? Math.floor(minutes / 60) : DEFAULT_HOURS,
minutes: minutes ? minutes % 60 : DEFAULT_MINUTES,
action: action,
});
}
protected override buildRequestData() {
this.data.markAllAsTouched();
this.data.updateValueAndValidity();
if (this.data.invalid) {
if (this.data.controls.type.hasError("required")) {
throw new Error(this.i18nService.t("maximumAllowedTimeoutRequired"));
}
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
}
let minutes = this.data.value.hours! * 60 + this.data.value.minutes!;
const type = this.data.value.type;
if (type === "custom") {
if (minutes <= 0) {
throw new Error(this.i18nService.t("sessionTimeoutPolicyInvalidTime"));
}
} else {
// For backwards compatibility, we set minutes to 8 hours, so older client's vault timeout will not be broken
minutes = DEFAULT_HOURS * 60 + DEFAULT_MINUTES;
}
return {
type,
minutes,
action: this.data.value.action,
};
}
private async confirmTypeChange(newType: SessionTimeoutType): Promise<boolean> {
if (newType === "never") {
const dialogRef = SessionTimeoutConfirmationNeverComponent.open(this.dialogService);
return !!(await firstValueFrom(dialogRef.closed));
} else if (newType === "onSystemLock") {
return await this.dialogService.openSimpleDialog({
type: "info",
title: { key: "sessionTimeoutConfirmationOnSystemLockTitle" },
content: { key: "sessionTimeoutConfirmationOnSystemLockDescription" },
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});
}
return true;
}
private updateFormControls(type: SessionTimeoutType) {
const hoursControl = this.data.controls.hours;
const minutesControl = this.data.controls.minutes;
if (type === "custom") {
hoursControl.enable();
minutesControl.enable();
} else {
hoursControl.disable();
minutesControl.disable();
}
}
}

View File

@@ -20,12 +20,16 @@ import { ProjectService } from "../projects/project.service";
import { projectAccessGuard } from "./project-access.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,

View File

@@ -21,6 +21,8 @@ import { IntegrationGridComponent } from "../../dirt/organization-integrations/i
import { IntegrationsComponent } from "./integrations.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-header",
template: "<div></div>",
@@ -28,6 +30,8 @@ import { IntegrationsComponent } from "./integrations.component";
})
class MockHeaderComponent {}
// 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: "sm-new-menu",
template: "<div></div>",

View File

@@ -3,6 +3,8 @@ import { Component } from "@angular/core";
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
import { IntegrationType } from "@bitwarden/common/enums";
// 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: "sm-integrations",
templateUrl: "./integrations.component.html",

View File

@@ -1,5 +1,7 @@
import { Component, OnInit } from "@angular/core";
// 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: "sm-layout",
templateUrl: "./layout.component.html",

View File

@@ -31,6 +31,8 @@ import { ServiceAccountService } from "../service-accounts/service-account.servi
import { SecretsManagerPortingApiService } from "../settings/services/sm-porting-api.service";
import { CountService } from "../shared/counts/count.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: "sm-navigation",
templateUrl: "./navigation.component.html",

View File

@@ -75,6 +75,8 @@ type OrganizationTasks = {
createServiceAccount: 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: "sm-overview",
templateUrl: "./overview.component.html",

View File

@@ -1,11 +1,15 @@
import { Component, Input } from "@angular/core";
// 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: "sm-section",
templateUrl: "./section.component.html",
standalone: false,
})
export class SectionComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() open = true;
/**

View File

@@ -25,6 +25,8 @@ export interface ProjectDeleteOperation {
projects: ProjectListView[];
}
// 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: "./project-delete-dialog.component.html",
standalone: false,

View File

@@ -25,6 +25,8 @@ export interface ProjectOperation {
projectId?: 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: "./project-dialog.component.html",
standalone: false,

View File

@@ -24,6 +24,8 @@ import {
import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-item.enum";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.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: "sm-project-people",
templateUrl: "./project-people.component.html",

View File

@@ -41,6 +41,8 @@ import {
import { SecretService } from "../../secrets/secret.service";
import { SecretsListComponent } from "../../shared/secrets-list.component";
import { ProjectService } from "../project.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: "sm-project-secrets",
templateUrl: "./project-secrets.component.html",

View File

@@ -22,6 +22,8 @@ import {
} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.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: "sm-project-service-accounts",
templateUrl: "./project-service-accounts.component.html",

View File

@@ -34,6 +34,8 @@ import {
} from "../dialog/project-dialog.component";
import { ProjectService } from "../project.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: "sm-project",
templateUrl: "./project.component.html",

View File

@@ -40,6 +40,8 @@ import {
} from "../dialog/project-dialog.component";
import { ProjectService } from "../project.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: "sm-projects",
templateUrl: "./projects.component.html",

View File

@@ -18,6 +18,8 @@ export interface SecretDeleteOperation {
secrets: SecretListView[];
}
// 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: "./secret-delete.component.html",
standalone: false,

View File

@@ -67,6 +67,8 @@ export interface SecretOperation {
organizationEnabled: 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({
templateUrl: "./secret-dialog.component.html",
standalone: false,

View File

@@ -10,6 +10,8 @@ export interface SecretViewDialogParams {
secretId: 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: "./secret-view-dialog.component.html",
standalone: false,

View File

@@ -34,6 +34,8 @@ import {
} from "./dialog/secret-view-dialog.component";
import { SecretService } from "./secret.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: "sm-secrets",
templateUrl: "./secrets.component.html",

View File

@@ -5,12 +5,16 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AccessTokenView } from "../models/view/access-token.view";
// 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: "sm-access-list",
templateUrl: "./access-list.component.html",
standalone: false,
})
export class AccessListComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
get tokens(): AccessTokenView[] {
return this._tokens;
@@ -21,7 +25,11 @@ export class AccessListComponent {
}
private _tokens: AccessTokenView[];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() newAccessTokenEvent = new EventEmitter();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() revokeAccessTokensEvent = new EventEmitter<AccessTokenView[]>();
protected selection = new SelectionModel<string>(true, []);

View File

@@ -24,6 +24,8 @@ import { ServiceAccountService } from "../service-account.service";
import { AccessService } from "./access.service";
import { AccessTokenCreateDialogComponent } from "./dialogs/access-token-create-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: "sm-access-tokens",
templateUrl: "./access-tokens.component.html",

View File

@@ -15,6 +15,8 @@ export interface AccessTokenOperation {
serviceAccountView: ServiceAccountView;
}
// 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: "./access-token-create-dialog.component.html",
standalone: false,

View File

@@ -12,6 +12,8 @@ export interface AccessTokenDetails {
accessToken: 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: "./access-token-dialog.component.html",
standalone: false,

View File

@@ -18,6 +18,8 @@ import { Subject, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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: "sm-expiration-options",
templateUrl: "./expiration-options.component.html",
@@ -40,8 +42,12 @@ export class ExpirationOptionsComponent
{
private destroy$ = new Subject<void>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() expirationDayOptions: number[];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() set touched(val: boolean) {
if (val) {
this.form.markAllAsTouched();

View File

@@ -24,6 +24,8 @@ class ServiceAccountConfig {
projects: ProjectListView[];
}
// 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: "sm-service-account-config",
templateUrl: "./config.component.html",

View File

@@ -25,6 +25,8 @@ export interface ServiceAccountDeleteOperation {
serviceAccounts: ServiceAccountView[];
}
// 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: "./service-account-delete-dialog.component.html",
standalone: false,

View File

@@ -24,6 +24,8 @@ export interface ServiceAccountOperation {
organizationEnabled: 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({
templateUrl: "./service-account-dialog.component.html",
standalone: false,

View File

@@ -17,6 +17,8 @@ import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export"
import { ServiceAccountEventLogApiService } from "./service-account-event-log-api.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: "sm-service-accounts-events",
templateUrl: "./service-accounts-events.component.html",

View File

@@ -20,12 +20,16 @@ import { ServiceAccountService } from "../service-account.service";
import { serviceAccountAccessGuard } from "./service-account-access.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,

View File

@@ -25,6 +25,8 @@ import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/
import { ApPermissionEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-permission.enum";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.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: "sm-service-account-people",
templateUrl: "./service-account-people.component.html",

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