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:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
28
.github/workflows/respond.yml
vendored
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,8 +187,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
||||
const notificationTestId = getNotificationTestId(notificationType);
|
||||
appendHeaderMessageToTitle(headerMessage);
|
||||
|
||||
document.body.innerHTML = "";
|
||||
|
||||
if (isVaultLocked) {
|
||||
const notificationConfig = {
|
||||
...notificationBarIframeInitData,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
141
apps/desktop/desktop_native/core/src/biometric_v2/linux.rs
Normal file
141
apps/desktop/desktop_native/core/src/biometric_v2/linux.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) || "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user