mirror of
https://github.com/bitwarden/browser
synced 2026-02-08 12:40:26 +00:00
Merge remote-tracking branch 'origin' into auth/pm-19877/notification-processing
This commit is contained in:
@@ -3657,25 +3657,6 @@
|
||||
"thisRequestIsNoLongerValid": {
|
||||
"message": "This request is no longer valid."
|
||||
},
|
||||
"areYouTryingToAccessYourAccount": {
|
||||
"message": "Are you trying to access your account?"
|
||||
},
|
||||
"logInConfirmedForEmailOnDevice": {
|
||||
"message": "Login confirmed for $EMAIL$ on $DEVICE$",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "name@example.com"
|
||||
},
|
||||
"device": {
|
||||
"content": "$2",
|
||||
"example": "iOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youDeniedALogInAttemptFromAnotherDevice": {
|
||||
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
|
||||
},
|
||||
"loginRequestHasAlreadyExpired": {
|
||||
"message": "Login request has already expired."
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="tw-mr-[5px] tw-mt-1">
|
||||
<div class="tw-me-2 tw-mt-1">
|
||||
<button
|
||||
*ngIf="currentAccount$ | async as currentAccount; else defaultButton"
|
||||
type="button"
|
||||
|
||||
@@ -245,6 +245,7 @@ export type OverlayBackgroundExtensionMessageHandlers = {
|
||||
editedCipher: () => void;
|
||||
deletedCipher: () => void;
|
||||
bgSaveCipher: () => void;
|
||||
updateOverlayCiphers: () => void;
|
||||
fido2AbortRequest: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
editedCipher: () => this.updateOverlayCiphers(),
|
||||
deletedCipher: () => this.updateOverlayCiphers(),
|
||||
bgSaveCipher: () => this.updateOverlayCiphers(),
|
||||
updateOverlayCiphers: () => this.updateOverlayCiphers(),
|
||||
fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id),
|
||||
};
|
||||
private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = {
|
||||
|
||||
@@ -20,10 +20,8 @@ export default class TabsBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateCurrentTabData();
|
||||
this.setupTabEventListeners();
|
||||
void this.updateCurrentTabData();
|
||||
void this.setupTabEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
@@ -122,7 +120,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* @param {AutofillExtensionMessage} message
|
||||
*/
|
||||
private async fillForm({ fillScript, pageDetailsUrl }: AutofillExtensionMessage) {
|
||||
if ((document.defaultView || window).location.href !== pageDetailsUrl) {
|
||||
if ((document.defaultView || window).location.href !== pageDetailsUrl || !fillScript) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +175,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
message: AutofillExtensionMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: any) => void,
|
||||
): boolean => {
|
||||
): boolean | null => {
|
||||
const command: string = message.command;
|
||||
const handler: CallableFunction | undefined = this.getExtensionMessageHandler(command);
|
||||
if (!handler) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { setupExtensionDisconnectAction } from "../utils";
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
@@ -9,7 +7,7 @@ if (document.readyState === "loading") {
|
||||
}
|
||||
|
||||
function loadAutofiller() {
|
||||
let pageHref: string = null;
|
||||
let pageHref: null | string = null;
|
||||
let filledThisHref = false;
|
||||
let delayFillTimeout: number;
|
||||
let doFillInterval: number | NodeJS.Timeout;
|
||||
@@ -51,9 +49,7 @@ function loadAutofiller() {
|
||||
sender: "autofiller",
|
||||
};
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
chrome.runtime.sendMessage(msg);
|
||||
void chrome.runtime.sendMessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
import DomElementVisibilityService from "../services/dom-element-visibility.service";
|
||||
@@ -11,7 +9,7 @@ import AutofillInit from "./autofill-init";
|
||||
|
||||
(function (windowContext) {
|
||||
if (!windowContext.bitwardenAutofillInit) {
|
||||
let inlineMenuContentService: AutofillInlineMenuContentService;
|
||||
let inlineMenuContentService: undefined | AutofillInlineMenuContentService;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
inlineMenuContentService = new AutofillInlineMenuContentService();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
import DomElementVisibilityService from "../services/dom-element-visibility.service";
|
||||
@@ -20,7 +18,7 @@ import AutofillInit from "./autofill-init";
|
||||
inlineMenuFieldQualificationService,
|
||||
);
|
||||
|
||||
let overlayNotificationsContentService: OverlayNotificationsContentService;
|
||||
let overlayNotificationsContentService: undefined | OverlayNotificationsContentService;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
overlayNotificationsContentService = new OverlayNotificationsContentService();
|
||||
}
|
||||
@@ -29,7 +27,7 @@ import AutofillInit from "./autofill-init";
|
||||
domQueryService,
|
||||
domElementVisibilityService,
|
||||
autofillOverlayContentService,
|
||||
null,
|
||||
undefined,
|
||||
overlayNotificationsContentService,
|
||||
);
|
||||
setupAutofillInitDisconnectAction(windowContext);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/content/autofill-inline-menu-content.service";
|
||||
import { OverlayNotificationsContentService } from "../overlay/notifications/content/overlay-notifications-content.service";
|
||||
import { AutofillOverlayContentService } from "../services/autofill-overlay-content.service";
|
||||
@@ -12,8 +10,8 @@ import AutofillInit from "./autofill-init";
|
||||
|
||||
(function (windowContext) {
|
||||
if (!windowContext.bitwardenAutofillInit) {
|
||||
let inlineMenuContentService: AutofillInlineMenuContentService;
|
||||
let overlayNotificationsContentService: OverlayNotificationsContentService;
|
||||
let inlineMenuContentService: undefined | AutofillInlineMenuContentService;
|
||||
let overlayNotificationsContentService: undefined | OverlayNotificationsContentService;
|
||||
if (globalThis.self === globalThis.top) {
|
||||
inlineMenuContentService = new AutofillInlineMenuContentService();
|
||||
overlayNotificationsContentService = new OverlayNotificationsContentService();
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import path, { dirname, join } from "path";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import type { StorybookConfig } from "@storybook/web-components-webpack5";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const currentDirectory = dirname(currentFile);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const getAbsolutePath = (value: string): string =>
|
||||
dirname(require.resolve(join(value, "package.json")));
|
||||
|
||||
@@ -43,7 +50,7 @@ const config: StorybookConfig = {
|
||||
if (config.resolve) {
|
||||
config.resolve.plugins = [
|
||||
new TsconfigPathsPlugin({
|
||||
configFile: path.resolve(__dirname, "../../../../../tsconfig.json"),
|
||||
configFile: resolve(currentDirectory, "../../../../../tsconfig.json"),
|
||||
}),
|
||||
] as any;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Spinner } from "../icons";
|
||||
|
||||
export type ActionButtonProps = {
|
||||
buttonText: string | TemplateResult;
|
||||
dataTestId?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
theme: Theme;
|
||||
@@ -17,6 +18,7 @@ export type ActionButtonProps = {
|
||||
|
||||
export function ActionButton({
|
||||
buttonText,
|
||||
dataTestId,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
theme,
|
||||
@@ -32,6 +34,7 @@ export function ActionButton({
|
||||
return html`
|
||||
<button
|
||||
class=${actionButtonStyles({ disabled, fullWidth, isLoading, theme })}
|
||||
data-testid="${dataTestId}"
|
||||
title=${buttonText}
|
||||
type="button"
|
||||
@click=${handleButtonClick}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Meta, StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { ThemeTypes } from "@bitwarden/common/platform/enums";
|
||||
|
||||
import { NotificationTypes } from "../../../../../notification/abstractions/notification-bar";
|
||||
import { getNotificationTestId } from "../../../../../notification/bar";
|
||||
import {
|
||||
AtRiskNotification,
|
||||
AtRiskNotificationProps,
|
||||
@@ -30,8 +32,10 @@ export default {
|
||||
},
|
||||
} as Meta<AtRiskNotificationProps>;
|
||||
|
||||
const Template = (args: AtRiskNotificationProps) => AtRiskNotification({ ...args });
|
||||
|
||||
const Template = (args: AtRiskNotificationProps) => {
|
||||
const notificationTestId = getNotificationTestId(NotificationTypes.AtRiskPassword);
|
||||
return AtRiskNotification({ ...args, notificationTestId });
|
||||
};
|
||||
export const Default: StoryObj<AtRiskNotificationProps> = {
|
||||
render: Template,
|
||||
};
|
||||
|
||||
@@ -18,11 +18,13 @@ export type AtRiskNotificationProps = NotificationBarIframeInitData & {
|
||||
handleCloseNotification: (e: Event) => void;
|
||||
} & {
|
||||
i18n: I18n;
|
||||
notificationTestId: string;
|
||||
};
|
||||
|
||||
export function AtRiskNotification({
|
||||
handleCloseNotification,
|
||||
i18n,
|
||||
notificationTestId,
|
||||
theme = ThemeTypes.Light,
|
||||
params,
|
||||
}: AtRiskNotificationProps) {
|
||||
@@ -33,7 +35,7 @@ export function AtRiskNotification({
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class=${atRiskNotificationContainerStyles(theme)}>
|
||||
<div data-testid="${notificationTestId}" class=${atRiskNotificationContainerStyles(theme)}>
|
||||
${NotificationHeader({
|
||||
handleCloseNotification,
|
||||
i18n,
|
||||
|
||||
@@ -26,6 +26,7 @@ export function AtRiskNotificationFooter({
|
||||
open(passwordChangeUri, "_blank");
|
||||
},
|
||||
buttonText: AdditionalTasksButtonContent({ buttonText: i18n.changePassword, theme }),
|
||||
dataTestId: "change-password-button",
|
||||
theme,
|
||||
fullWidth: false,
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
(function () {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
chrome.runtime.sendMessage({ command: "triggerAutofillScriptInjection" });
|
||||
void chrome.runtime.sendMessage({ command: "triggerAutofillScriptInjection" });
|
||||
})();
|
||||
|
||||
@@ -200,7 +200,7 @@ export function getNotificationTestId(
|
||||
[NotificationTypes.Unlock]: "unlock-notification-bar",
|
||||
[NotificationTypes.Add]: "save-notification-bar",
|
||||
[NotificationTypes.Change]: "update-notification-bar",
|
||||
[NotificationTypes.AtRiskPassword]: "at-risk-password-notification-bar",
|
||||
[NotificationTypes.AtRiskPassword]: "at-risk-notification-bar",
|
||||
}[notificationType];
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
|
||||
type: notificationBarIframeInitData.type as NotificationType,
|
||||
theme: resolvedTheme,
|
||||
i18n,
|
||||
notificationTestId,
|
||||
params: initData.params,
|
||||
handleCloseNotification,
|
||||
}),
|
||||
|
||||
@@ -50,6 +50,15 @@ export class AutoFillConstants {
|
||||
|
||||
static readonly SearchFieldNames: string[] = ["search", "query", "find", "go"];
|
||||
|
||||
static readonly NewEmailFieldKeywords: string[] = [
|
||||
"new-email",
|
||||
"newemail",
|
||||
"new email",
|
||||
"neue e-mail",
|
||||
];
|
||||
|
||||
static readonly NewsletterFormNames: string[] = ["newsletter"];
|
||||
|
||||
static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"];
|
||||
|
||||
static readonly PasswordFieldExcludeList: string[] = [
|
||||
|
||||
@@ -213,9 +213,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
this.autofillScriptPortsSet.delete(port);
|
||||
});
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.injectAutofillScriptsInAllTabs();
|
||||
void this.injectAutofillScriptsInAllTabs();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,9 +468,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
await this.cipherService.updateLastUsedDate(options.cipher.id, activeAccount.id);
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
BrowserApi.tabSendMessage(
|
||||
void BrowserApi.tabSendMessage(
|
||||
tab,
|
||||
{
|
||||
command: options.autoSubmitLogin ? "triggerAutoSubmitLogin" : "fillForm",
|
||||
@@ -502,9 +498,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
);
|
||||
|
||||
if (didAutofill) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id);
|
||||
await this.eventCollectionService.collect(
|
||||
EventType.Cipher_ClientAutofilled,
|
||||
options.cipher.id,
|
||||
);
|
||||
if (totp !== null) {
|
||||
return totp;
|
||||
} else {
|
||||
|
||||
@@ -58,6 +58,8 @@ export class InlineMenuFieldQualificationService
|
||||
"neue e-mail",
|
||||
"pwdcheck",
|
||||
];
|
||||
private newEmailFieldKeywords = new Set(AutoFillConstants.NewEmailFieldKeywords);
|
||||
private newsletterFormKeywords = new Set(AutoFillConstants.NewsletterFormNames);
|
||||
private updatePasswordFieldKeywords = [
|
||||
"update password",
|
||||
"change password",
|
||||
@@ -152,6 +154,61 @@ export class InlineMenuFieldQualificationService
|
||||
private totpFieldAutocompleteValue = "one-time-code";
|
||||
private premiumEnabled = false;
|
||||
|
||||
/**
|
||||
* Validates the provided field to indicate if the field is a new email field used for account creation/registration.
|
||||
*
|
||||
* @param field - The field to validate
|
||||
*/
|
||||
private isExplicitIdentityEmailField(field: AutofillField): boolean {
|
||||
const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder];
|
||||
for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
|
||||
if (!matchFieldAttributeValues[attrIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let keywordIndex = 0; keywordIndex < matchFieldAttributeValues.length; keywordIndex++) {
|
||||
if (this.newEmailFieldKeywords.has(matchFieldAttributeValues[attrIndex])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the provided form to indicate if the form is related to newsletter registration.
|
||||
*
|
||||
* @param parentForm - The form to validate
|
||||
*/
|
||||
private isNewsletterForm(parentForm: any): boolean {
|
||||
if (!parentForm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchFieldAttributeValues = [
|
||||
parentForm.type,
|
||||
parentForm.htmlName,
|
||||
parentForm.htmlID,
|
||||
parentForm.placeholder,
|
||||
];
|
||||
|
||||
for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
|
||||
const attrValue = matchFieldAttributeValues[attrIndex];
|
||||
if (!attrValue || typeof attrValue !== "string") {
|
||||
continue;
|
||||
}
|
||||
const attrValueLower = attrValue.toLowerCase();
|
||||
for (const keyword of this.newsletterFormKeywords) {
|
||||
if (attrValueLower.includes(keyword.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
void Promise.all([
|
||||
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
|
||||
@@ -300,7 +357,11 @@ export class InlineMenuFieldQualificationService
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues);
|
||||
return (
|
||||
// Recognize explicit identity email fields (like id="new-email")
|
||||
this.isFieldForIdentityEmail(field) ||
|
||||
this.fieldContainsAutocompleteValues(field, this.identityAutocompleteValues)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,6 +458,12 @@ export class InlineMenuFieldQualificationService
|
||||
): boolean {
|
||||
// If the provided field is set with an autocomplete of "username", we should assume that
|
||||
// the page developer intends for this field to be interpreted as a username field.
|
||||
|
||||
// Exclude non-login email field from being treated as a login username field
|
||||
if (this.isExplicitIdentityEmailField(field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.fieldContainsAutocompleteValues(field, this.loginUsernameAutocompleteValues)) {
|
||||
const newPasswordFieldsInPageDetails = pageDetails.fields.filter(
|
||||
(field) => field.viewable && this.isNewPasswordField(field),
|
||||
@@ -415,6 +482,10 @@ export class InlineMenuFieldQualificationService
|
||||
const parentForm = pageDetails.forms[field.form];
|
||||
const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField);
|
||||
|
||||
if (this.isNewsletterForm(parentForm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the field is not structured within a form, we need to identify if the field is used in conjunction
|
||||
// with a password field. If that's the case, then we should assume that it is a form field element.
|
||||
if (!parentForm) {
|
||||
@@ -822,9 +893,14 @@ export class InlineMenuFieldQualificationService
|
||||
* @param field - The field to validate
|
||||
*/
|
||||
isFieldForIdentityEmail = (field: AutofillField): boolean => {
|
||||
if (this.isExplicitIdentityEmailField(field)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this.fieldContainsAutocompleteValues(field, this.emailAutocompleteValue) ||
|
||||
field.type === "email"
|
||||
field.type === "email" ||
|
||||
field.htmlName === "email"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -15,7 +13,7 @@ const IdleInterval = 60 * 5; // 5 minutes
|
||||
|
||||
export default class IdleBackground {
|
||||
private idle: typeof chrome.idle | typeof browser.idle | null;
|
||||
private idleTimer: number | NodeJS.Timeout = null;
|
||||
private idleTimer: null | number | NodeJS.Timeout = null;
|
||||
private idleState = "active";
|
||||
|
||||
constructor(
|
||||
@@ -80,9 +78,8 @@ export default class IdleBackground {
|
||||
globalThis.clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.idle.queryState(IdleInterval, (state: string) => {
|
||||
|
||||
void this.idle?.queryState(IdleInterval, (state: string) => {
|
||||
if (state !== this.idleState) {
|
||||
this.idleState = state;
|
||||
handler(state);
|
||||
|
||||
@@ -904,6 +904,7 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
);
|
||||
this.folderService = new FolderService(
|
||||
this.keyService,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<!--
|
||||
end padding is less than start padding to prioritize visual alignment when icon buttons are used at the end of the end slot.
|
||||
other elements used at the end of the end slot may need to add their own margin/padding to achieve visual alignment.
|
||||
-->
|
||||
<header
|
||||
class="tw-p-3 bit-compact:tw-p-2 tw-pl-4 bit-compact:tw-pl-3 tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
|
||||
class="tw-py-3 bit-compact:tw-py-2 tw-pe-1 bit-compact:tw-pe-0.5 tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
'tw-bg-background-alt tw-border-transparent':
|
||||
this.background === 'alt' && !pageContentScrolled(),
|
||||
'tw-bg-background tw-border-secondary-300':
|
||||
(this.background === 'alt' && pageContentScrolled()) || this.background === 'default',
|
||||
'tw-ps-4 bit-compact:tw-ps-3': !showBackButton,
|
||||
'tw-ps-1 bit-compact:tw-ps-0': showBackButton,
|
||||
}"
|
||||
>
|
||||
<div class="tw-max-w-screen-sm tw-mx-auto tw-flex tw-justify-between tw-w-full">
|
||||
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
|
||||
<button
|
||||
class="-tw-ml-1"
|
||||
bitIconButton="bwi-angle-left"
|
||||
type="button"
|
||||
*ngIf="showBackButton"
|
||||
|
||||
@@ -117,7 +117,7 @@ class MockPopoutButtonComponent {}
|
||||
@Component({
|
||||
selector: "mock-current-account",
|
||||
template: `
|
||||
<button class="tw-bg-transparent tw-border-none" type="button">
|
||||
<button class="tw-bg-transparent tw-border-none tw-p-0 tw-me-1" type="button">
|
||||
<bit-avatar text="Ash Ketchum" size="small"></bit-avatar>
|
||||
</button>
|
||||
`,
|
||||
@@ -654,7 +654,7 @@ export const WithVirtualScrollChild: Story = {
|
||||
<bit-section>
|
||||
@defer (on immediate) {
|
||||
<bit-item-group aria-label="Mock Vault Items">
|
||||
<cdk-virtual-scroll-viewport itemSize="61" bitScrollLayout>
|
||||
<cdk-virtual-scroll-viewport itemSize="59" bitScrollLayout>
|
||||
<bit-item *cdkVirtualFor="let item of data; index as i">
|
||||
<button type="button" bit-item-content>
|
||||
<i
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -163,7 +163,7 @@ describe("OpenAttachmentsComponent", () => {
|
||||
it("sets `cipherIsAPartOfFreeOrg` to true when the cipher is a part of a free organization", async () => {
|
||||
cipherView.organizationId = "888-333-333";
|
||||
org.productTierType = ProductTierType.Free;
|
||||
org.id = cipherView.organizationId;
|
||||
org.id = cipherView.organizationId as OrganizationId;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("OpenAttachmentsComponent", () => {
|
||||
it("sets `cipherIsAPartOfFreeOrg` to false when the organization is not free", async () => {
|
||||
cipherView.organizationId = "888-333-333";
|
||||
org.productTierType = ProductTierType.Families;
|
||||
org.id = cipherView.organizationId;
|
||||
org.id = cipherView.organizationId as OrganizationId;
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CollectionExport } from "@bitwarden/common/models/export/collection.export";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SelectionReadOnly } from "../selection-read-only";
|
||||
|
||||
export class OrganizationCollectionRequest extends CollectionExport {
|
||||
static template(): OrganizationCollectionRequest {
|
||||
const req = new OrganizationCollectionRequest();
|
||||
req.organizationId = "00000000-0000-0000-0000-000000000000";
|
||||
req.organizationId = "00000000-0000-0000-0000-000000000000" as OrganizationId;
|
||||
req.name = "Collection name";
|
||||
req.externalId = null;
|
||||
req.groups = [SelectionReadOnly.template(), SelectionReadOnly.template()];
|
||||
|
||||
@@ -723,6 +723,7 @@ export class ServiceContainer {
|
||||
this.accountService,
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
);
|
||||
|
||||
this.folderService = new FolderService(
|
||||
|
||||
@@ -149,11 +149,11 @@ export class SendProgram extends BaseProgram {
|
||||
|
||||
private templateCommand(): Command {
|
||||
return new Command("template")
|
||||
.argument("<object>", "Valid objects are: send.text, send.file")
|
||||
.argument("<object>", "Valid objects are: send.text, text, send.file, file")
|
||||
.description("Get json templates for send objects")
|
||||
.action((options: OptionValues) =>
|
||||
this.processResponse(new SendTemplateCommand().run(options.object)),
|
||||
);
|
||||
.action((object: string) => {
|
||||
this.processResponse(new SendTemplateCommand().run(object));
|
||||
});
|
||||
}
|
||||
|
||||
private getCommand(): Command {
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::os::windows::ffi::OsStringExt;
|
||||
|
||||
use windows::Win32::Foundation::{GetLastError, HWND};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
BlockInput, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
|
||||
KEYEVENTF_UNICODE,
|
||||
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE,
|
||||
VIRTUAL_KEY,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW,
|
||||
@@ -28,21 +28,31 @@ pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
|
||||
///
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
pub fn type_input(input: Vec<u16>) -> Result<(), ()> {
|
||||
const TAB_KEY: u16 = 9;
|
||||
let mut keyboard_inputs: Vec<INPUT> = Vec::new();
|
||||
|
||||
// Release hotkeys
|
||||
keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x12)); // alt
|
||||
keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x11)); // ctrl
|
||||
keyboard_inputs.push(build_unicode_input(InputKeyPress::Up, 105)); // i
|
||||
|
||||
for i in input {
|
||||
let next_down_input = build_input(InputKeyPress::Down, i);
|
||||
let next_up_input = build_input(InputKeyPress::Up, i);
|
||||
let next_down_input = if i == TAB_KEY {
|
||||
build_virtual_key_input(InputKeyPress::Down, i as u8)
|
||||
} else {
|
||||
build_unicode_input(InputKeyPress::Down, i)
|
||||
};
|
||||
let next_up_input = if i == TAB_KEY {
|
||||
build_virtual_key_input(InputKeyPress::Up, i as u8)
|
||||
} else {
|
||||
build_unicode_input(InputKeyPress::Up, i)
|
||||
};
|
||||
|
||||
keyboard_inputs.push(next_down_input);
|
||||
keyboard_inputs.push(next_up_input);
|
||||
}
|
||||
|
||||
let _ = block_input(true);
|
||||
let result = send_input(keyboard_inputs);
|
||||
let _ = block_input(false);
|
||||
|
||||
result
|
||||
send_input(keyboard_inputs)
|
||||
}
|
||||
|
||||
/// Gets the foreground window handle.
|
||||
@@ -103,11 +113,11 @@ enum InputKeyPress {
|
||||
Up,
|
||||
}
|
||||
|
||||
/// A function for easily building keyboard INPUT structs used in SendInput().
|
||||
/// A function for easily building keyboard unicode INPUT structs used in SendInput().
|
||||
///
|
||||
/// Before modifying this function, make sure you read the SendInput() documentation:
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
fn build_input(key_press: InputKeyPress, character: u16) -> INPUT {
|
||||
fn build_unicode_input(key_press: InputKeyPress, character: u16) -> INPUT {
|
||||
match key_press {
|
||||
InputKeyPress::Down => INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
@@ -136,14 +146,37 @@ fn build_input(key_press: InputKeyPress, character: u16) -> INPUT {
|
||||
}
|
||||
}
|
||||
|
||||
/// Block keyboard and mouse input events. This prevents the hotkey
|
||||
/// key presses from interfering with the input sent via SendInput().
|
||||
/// A function for easily building keyboard virtual-key INPUT structs used in SendInput().
|
||||
///
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-blockinput
|
||||
fn block_input(block: bool) -> Result<(), ()> {
|
||||
match unsafe { BlockInput(block) } {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => Err(()),
|
||||
/// Before modifying this function, make sure you read the SendInput() documentation:
|
||||
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
|
||||
fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT {
|
||||
match key_press {
|
||||
InputKeyPress::Down => INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(virtual_key as u16),
|
||||
wScan: Default::default(),
|
||||
dwFlags: Default::default(),
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
InputKeyPress::Up => INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(virtual_key as u16),
|
||||
wScan: Default::default(),
|
||||
dwFlags: KEYEVENTF_KEYUP,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
|
||||
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
|
||||
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
||||
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
DESKTOP_SSO_CALLBACK,
|
||||
LogoutReason,
|
||||
@@ -476,7 +477,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
case "openLoginApproval":
|
||||
if (message.notificationId != null) {
|
||||
this.dialogService.closeAll();
|
||||
const dialogRef = LoginApprovalComponent.open(this.dialogService, {
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: message.notificationId,
|
||||
});
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
|
||||
@@ -91,6 +91,7 @@ export class InitService {
|
||||
containerService.attachToGlobal(this.win);
|
||||
|
||||
await this.autofillService.init();
|
||||
await this.autotypeService.init();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Router } from "@angular/router";
|
||||
import { Subject, merge } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "@bitwarden/angular/auth/login-approval";
|
||||
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import {
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
} from "@bitwarden/auth/angular";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LoginApprovalComponentServiceAbstraction,
|
||||
LoginEmailService,
|
||||
SsoUrlService,
|
||||
} from "@bitwarden/auth/common";
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { ClientType, DeviceType } from "@bitwarden/common/enums";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -107,7 +107,7 @@ import {
|
||||
import { LockComponentService } from "@bitwarden/key-management-ui";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
|
||||
import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service";
|
||||
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
|
||||
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
@@ -444,8 +444,8 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalComponentServiceAbstraction,
|
||||
useClass: DesktopLoginApprovalComponentService,
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useClass: DesktopLoginApprovalDialogComponentService,
|
||||
deps: [I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -455,17 +455,15 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DesktopAutotypeService,
|
||||
useFactory: (
|
||||
configService: ConfigService,
|
||||
globalStateProvider: GlobalStateProvider,
|
||||
platformUtilsService: PlatformUtilsServiceAbstraction,
|
||||
) =>
|
||||
new DesktopAutotypeService(
|
||||
configService,
|
||||
globalStateProvider,
|
||||
platformUtilsService.getDevice() === DeviceType.WindowsDesktop,
|
||||
),
|
||||
deps: [ConfigService, GlobalStateProvider, PlatformUtilsServiceAbstraction],
|
||||
useClass: DesktopAutotypeService,
|
||||
deps: [
|
||||
AccountService,
|
||||
AuthService,
|
||||
CipherServiceAbstraction,
|
||||
ConfigService,
|
||||
GlobalStateProvider,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DesktopLoginApprovalComponentService } from "./desktop-login-approval-component.service";
|
||||
import { DesktopLoginApprovalDialogComponentService } from "./desktop-login-approval-dialog-component.service";
|
||||
|
||||
describe("DesktopLoginApprovalComponentService", () => {
|
||||
let service: DesktopLoginApprovalComponentService;
|
||||
describe("DesktopLoginApprovalDialogComponentService", () => {
|
||||
let service: DesktopLoginApprovalDialogComponentService;
|
||||
let i18nService: MockProxy<I18nServiceAbstraction>;
|
||||
let originalIpc: any;
|
||||
|
||||
@@ -31,12 +31,12 @@ describe("DesktopLoginApprovalComponentService", () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DesktopLoginApprovalComponentService,
|
||||
DesktopLoginApprovalDialogComponentService,
|
||||
{ provide: I18nServiceAbstraction, useValue: i18nService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DesktopLoginApprovalComponentService);
|
||||
service = TestBed.inject(DesktopLoginApprovalDialogComponentService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -54,7 +54,7 @@ describe("DesktopLoginApprovalComponentService", () => {
|
||||
const message = `Confirm access attempt for ${email}`;
|
||||
const closeText = "Close";
|
||||
|
||||
const loginApprovalComponent = { email } as LoginApprovalComponent;
|
||||
const loginApprovalDialogComponent = { email } as LoginApprovalDialogComponent;
|
||||
i18nService.t.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case "accountAccessRequested":
|
||||
@@ -71,18 +71,20 @@ describe("DesktopLoginApprovalComponentService", () => {
|
||||
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false);
|
||||
jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue();
|
||||
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email);
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
|
||||
|
||||
expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText);
|
||||
});
|
||||
|
||||
it("does not call ipc.auth.loginRequest when window is visible", async () => {
|
||||
const loginApprovalComponent = { email: "test@bitwarden.com" } as LoginApprovalComponent;
|
||||
const loginApprovalDialogComponent = {
|
||||
email: "test@bitwarden.com",
|
||||
} as LoginApprovalDialogComponent;
|
||||
|
||||
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true);
|
||||
jest.spyOn(ipc.auth, "loginRequest");
|
||||
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email);
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
|
||||
|
||||
expect(ipc.auth.loginRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular";
|
||||
import { LoginApprovalComponentServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import {
|
||||
DefaultLoginApprovalDialogComponentService,
|
||||
LoginApprovalDialogComponentServiceAbstraction,
|
||||
} from "@bitwarden/angular/auth/login-approval";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@Injectable()
|
||||
export class DesktopLoginApprovalComponentService
|
||||
extends DefaultLoginApprovalComponentService
|
||||
implements LoginApprovalComponentServiceAbstraction
|
||||
export class DesktopLoginApprovalDialogComponentService
|
||||
extends DefaultLoginApprovalDialogComponentService
|
||||
implements LoginApprovalDialogComponentServiceAbstraction
|
||||
{
|
||||
constructor(private i18nService: I18nServiceAbstraction) {
|
||||
super();
|
||||
@@ -1,33 +1,70 @@
|
||||
import { autotype } from "@bitwarden/desktop-napi";
|
||||
import { ipcMain, globalShortcut } from "electron";
|
||||
|
||||
import { DesktopAutotypeService } from "../services/desktop-autotype.service";
|
||||
import { autotype } from "@bitwarden/desktop-napi";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
|
||||
|
||||
export class MainDesktopAutotypeService {
|
||||
constructor(private desktopAutotypeService: DesktopAutotypeService) {}
|
||||
keySequence: string = "Alt+CommandOrControl+I";
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private windowMain: WindowMain,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
this.desktopAutotypeService.autotypeEnabled$.subscribe((enabled) => {
|
||||
if (enabled) {
|
||||
ipcMain.on("autofill.configureAutotype", (event, data) => {
|
||||
if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) {
|
||||
this.enableAutotype();
|
||||
} else {
|
||||
} else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) {
|
||||
this.disableAutotype();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("autofill.completeAutotypeRequest", (event, data) => {
|
||||
const { response } = data;
|
||||
|
||||
if (
|
||||
stringIsNotUndefinedNullAndEmpty(response.username) &&
|
||||
stringIsNotUndefinedNullAndEmpty(response.password)
|
||||
) {
|
||||
this.doAutotype(response.username, response.password);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: this will call into desktop native code
|
||||
private enableAutotype() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Enabling Autotype...");
|
||||
disableAutotype() {
|
||||
if (globalShortcut.isRegistered(this.keySequence)) {
|
||||
globalShortcut.unregister(this.keySequence);
|
||||
}
|
||||
|
||||
const result = autotype.getForegroundWindowTitle();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Window Title: " + result);
|
||||
this.logService.info("Autotype disabled.");
|
||||
}
|
||||
|
||||
// TODO: this will call into desktop native code
|
||||
private disableAutotype() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Disabling Autotype...");
|
||||
private enableAutotype() {
|
||||
const result = globalShortcut.register(this.keySequence, () => {
|
||||
const windowTitle = autotype.getForegroundWindowTitle();
|
||||
|
||||
this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
|
||||
windowTitle,
|
||||
});
|
||||
});
|
||||
|
||||
result
|
||||
? this.logService.info("Autotype enabled.")
|
||||
: this.logService.info("Enabling autotype failed.");
|
||||
}
|
||||
|
||||
private doAutotype(username: string, password: string) {
|
||||
const inputPattern = username + "\t" + password;
|
||||
const inputArray = new Array<number>(inputPattern.length);
|
||||
|
||||
for (let i = 0; i < inputPattern.length; i++) {
|
||||
inputArray[i] = inputPattern.charCodeAt(i);
|
||||
}
|
||||
|
||||
autotype.typeInput(inputArray);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,4 +127,43 @@ export default {
|
||||
},
|
||||
);
|
||||
},
|
||||
configureAutotype: (enabled: boolean) => {
|
||||
ipcRenderer.send("autofill.configureAutotype", { enabled });
|
||||
},
|
||||
listenAutotypeRequest: (
|
||||
fn: (
|
||||
windowTitle: string,
|
||||
completeCallback: (
|
||||
error: Error | null,
|
||||
response: { username?: string; password?: string },
|
||||
) => void,
|
||||
) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
"autofill.listenAutotypeRequest",
|
||||
(
|
||||
event,
|
||||
data: {
|
||||
windowTitle: string;
|
||||
},
|
||||
) => {
|
||||
const { windowTitle } = data;
|
||||
|
||||
fn(windowTitle, (error, response) => {
|
||||
if (error) {
|
||||
ipcRenderer.send("autofill.completeError", {
|
||||
windowTitle,
|
||||
error: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRenderer.send("autofill.completeAutotypeRequest", {
|
||||
windowTitle,
|
||||
response,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { combineLatest, map, Observable, of } from "rxjs";
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
GlobalStateProvider,
|
||||
AUTOTYPE_SETTINGS_DISK,
|
||||
KeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean>(
|
||||
AUTOTYPE_SETTINGS_DISK,
|
||||
@@ -20,28 +28,83 @@ export class DesktopAutotypeService {
|
||||
autotypeEnabled$: Observable<boolean> = of(false);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private authService: AuthService,
|
||||
private cipherService: CipherService,
|
||||
private configService: ConfigService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private isWindows: boolean,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
if (this.isWindows) {
|
||||
ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => {
|
||||
const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle);
|
||||
const firstCipher = possibleCiphers?.at(0);
|
||||
|
||||
return callback(null, {
|
||||
username: firstCipher?.login?.username,
|
||||
password: firstCipher?.login?.password,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
|
||||
this.autotypeEnabled$ = combineLatest([
|
||||
this.autotypeEnabledState.state$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype),
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId)),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag]) =>
|
||||
autotypeEnabled && windowsDesktopAutotypeFeatureFlag,
|
||||
([autotypeEnabled, windowsDesktopAutotypeFeatureFlag, authStatus]) =>
|
||||
autotypeEnabled &&
|
||||
windowsDesktopAutotypeFeatureFlag &&
|
||||
authStatus == AuthenticationStatus.Unlocked,
|
||||
),
|
||||
);
|
||||
|
||||
this.autotypeEnabled$.subscribe((enabled) => {
|
||||
ipc.autofill.configureAutotype(enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
async setAutotypeEnabledState(enabled: boolean): Promise<void> {
|
||||
await this.autotypeEnabledState.update(() => enabled, {
|
||||
shouldUpdate: (currentlyEnabled) => currentlyEnabled !== enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async matchCiphersToWindowTitle(windowTitle: string): Promise<CipherView[]> {
|
||||
const URI_PREFIX = "APP:";
|
||||
windowTitle = windowTitle.toLowerCase();
|
||||
|
||||
const ciphers = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
switchMap((userId) => this.cipherService.cipherViews$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
const possibleCiphers = ciphers.filter((c) => {
|
||||
return (
|
||||
c.login?.username &&
|
||||
c.login?.password &&
|
||||
c.deletedDate == null &&
|
||||
c.login?.uris.some((u) => {
|
||||
if (u.uri?.indexOf(URI_PREFIX) !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uri = u.uri.substring(4).toLowerCase();
|
||||
|
||||
return windowTitle.indexOf(uri) > -1;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return possibleCiphers;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3027,9 +3027,6 @@
|
||||
"message": "Toggle character count",
|
||||
"description": "'Character count' describes a feature that displays a number next to each character of the password."
|
||||
},
|
||||
"areYouTryingToAccessYourAccount": {
|
||||
"message": "Are you trying to access your account?"
|
||||
},
|
||||
"accessAttemptBy": {
|
||||
"message": "Access attempt by $EMAIL$",
|
||||
"placeholders": {
|
||||
@@ -3039,6 +3036,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequestApprovedForEmailOnDevice": {
|
||||
"message": "Login request approved for $EMAIL$ on $DEVICE$",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "name@example.com"
|
||||
},
|
||||
"device": {
|
||||
"content": "$2",
|
||||
"example": "Web app - Chrome"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youDeniedLoginAttemptFromAnotherDevice": {
|
||||
"message": "You denied a login attempt from another device. If this was you, try to log in with the device again."
|
||||
},
|
||||
"webApp": {
|
||||
"message": "Web app"
|
||||
},
|
||||
"mobile": {
|
||||
"message": "Mobile",
|
||||
"description": "Mobile app"
|
||||
},
|
||||
"extension": {
|
||||
"message": "Extension",
|
||||
"description": "Browser extension/addon"
|
||||
},
|
||||
"desktop": {
|
||||
"message": "Desktop",
|
||||
"description": "Desktop app"
|
||||
},
|
||||
"cli": {
|
||||
"message": "CLI"
|
||||
},
|
||||
"sdk": {
|
||||
"message": "SDK",
|
||||
"description": "Software Development Kit"
|
||||
},
|
||||
"server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"loginRequest": {
|
||||
"message": "Login request"
|
||||
},
|
||||
"deviceType": {
|
||||
"message": "Device Type"
|
||||
},
|
||||
@@ -3054,22 +3095,6 @@
|
||||
"denyAccess": {
|
||||
"message": "Deny access"
|
||||
},
|
||||
"logInConfirmedForEmailOnDevice": {
|
||||
"message": "Login confirmed for $EMAIL$ on $DEVICE$",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "name@example.com"
|
||||
},
|
||||
"device": {
|
||||
"content": "$2",
|
||||
"example": "iOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youDeniedALogInAttemptFromAnotherDevice": {
|
||||
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
|
||||
},
|
||||
"justNow": {
|
||||
"message": "Just now"
|
||||
},
|
||||
|
||||
@@ -37,7 +37,6 @@ import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-
|
||||
import { MainDesktopAutotypeService } from "./autofill/main/main-desktop-autotype.service";
|
||||
import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service";
|
||||
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutotypeService } from "./autofill/services/desktop-autotype.service";
|
||||
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
||||
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
||||
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
||||
@@ -48,7 +47,6 @@ import { PowerMonitorMain } from "./main/power-monitor.main";
|
||||
import { TrayMain } from "./main/tray.main";
|
||||
import { UpdaterMain } from "./main/updater.main";
|
||||
import { WindowMain } from "./main/window.main";
|
||||
import { SlimConfigService } from "./platform/config/slim-config.service";
|
||||
import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main";
|
||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||
@@ -307,13 +305,22 @@ export class Main {
|
||||
void this.nativeAutofillMain.init();
|
||||
|
||||
this.mainDesktopAutotypeService = new MainDesktopAutotypeService(
|
||||
new DesktopAutotypeService(
|
||||
new SlimConfigService(this.environmentService, globalStateProvider),
|
||||
globalStateProvider,
|
||||
process.platform === "win32",
|
||||
),
|
||||
this.logService,
|
||||
this.windowMain,
|
||||
);
|
||||
this.mainDesktopAutotypeService.init();
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
this.mainDesktopAutotypeService.init();
|
||||
})
|
||||
.catch((reason) => {
|
||||
this.logService.error("Error initializing Autotype.", reason);
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
this.mainDesktopAutotypeService.disableAutotype();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrap() {
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.7.1",
|
||||
"version": "2025.8.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.8.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { combineLatest, map, Observable, throwError } from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
FeatureFlagValueType,
|
||||
getFeatureFlagValue,
|
||||
} from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { ServerSettings } from "@bitwarden/common/platform/models/domain/server-settings";
|
||||
import { GLOBAL_SERVER_CONFIGURATIONS } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
import { GlobalStateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/*
|
||||
NOT FOR GENERAL USE
|
||||
|
||||
If you have more uses for the config service in the main process,
|
||||
please reach out to platform.
|
||||
*/
|
||||
export class SlimConfigService implements ConfigService {
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
) {}
|
||||
|
||||
serverConfig$: Observable<ServerConfig> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
serverSettings$: Observable<ServerSettings> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
cloudRegion$: Observable<Region> = throwError(() => {
|
||||
return new Error("Method not implemented.");
|
||||
});
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return combineLatest([
|
||||
this.environmentService.environment$,
|
||||
this.globalStateProvider.get(GLOBAL_SERVER_CONFIGURATIONS).state$,
|
||||
]).pipe(
|
||||
map(([environment, serverConfigMap]) =>
|
||||
getFeatureFlagValue(serverConfigMap?.[environment.getApiUrl()], key),
|
||||
),
|
||||
);
|
||||
}
|
||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(
|
||||
key: Flag,
|
||||
userId: UserId,
|
||||
): Observable<FeatureFlagValueType<Flag>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getFeatureFlag<Flag extends FeatureFlag>(key: Flag): Promise<FeatureFlagValueType<Flag>> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer): Observable<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
ensureConfigFetched(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -98,3 +98,11 @@ export function cleanUserAgent(userAgent: string): string {
|
||||
.replace(userAgentItem("Bitwarden", " "), "")
|
||||
.replace(userAgentItem("Electron", " "), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the provided string is not undefined, not null, and not empty.
|
||||
* Otherwise, returns `false`.
|
||||
*/
|
||||
export function stringIsNotUndefinedNullAndEmpty(str: string): boolean {
|
||||
return str?.length > 0;
|
||||
}
|
||||
|
||||
@@ -466,15 +466,24 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedCipher = await this.cipherService.get(
|
||||
this.cipherId as CipherId,
|
||||
this.activeUserId as UserId,
|
||||
);
|
||||
const updatedCipherView = await this.cipherService.decrypt(
|
||||
updatedCipher,
|
||||
this.activeUserId as UserId,
|
||||
// The encrypted state of ciphers is updated when an attachment is added,
|
||||
// but the cache is also cleared. Depending on timing, `cipherService.get` can return the
|
||||
// old cipher. Retrieve the updated cipher from `cipherViews$`,
|
||||
// which refreshes after the cached is cleared.
|
||||
const updatedCipherView = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||
filter((c) => !!c),
|
||||
map((ciphers) => ciphers.find((c) => c.id === this.cipherId)),
|
||||
),
|
||||
);
|
||||
|
||||
// `find` can return undefined but that shouldn't happen as
|
||||
// this would mean that the cipher was deleted.
|
||||
// To make TypeScript happy, exit early if it isn't found.
|
||||
if (!updatedCipherView) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipherFormComponent.patchCipher((currentCipher) => {
|
||||
currentCipher.attachments = updatedCipherView.attachments;
|
||||
currentCipher.revisionDate = updatedCipherView.revisionDate;
|
||||
@@ -499,7 +508,6 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
if (cipher.decryptionFailure) {
|
||||
invokeMenu(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cipher.isDeleted) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -84,6 +84,7 @@
|
||||
{{ trashCleanupWarning }}
|
||||
</bit-callout>
|
||||
<app-vault-items
|
||||
#vaultItems
|
||||
[ciphers]="ciphers"
|
||||
[collections]="collections"
|
||||
[allCollections]="allCollections"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
} from "@bitwarden/vault";
|
||||
import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service";
|
||||
import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component";
|
||||
|
||||
import { BillingNotificationService } from "../../../billing/services/billing-notification.service";
|
||||
import {
|
||||
@@ -204,6 +205,8 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
|
||||
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
||||
|
||||
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<CipherView>;
|
||||
|
||||
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
switchMap((id) =>
|
||||
@@ -278,9 +281,16 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
const filter$ = this.routedVaultFilterService.filter$;
|
||||
|
||||
// FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault,
|
||||
// but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here,
|
||||
// but really we should change to using our own vault filter model that only represents valid states in AC.
|
||||
const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId =>
|
||||
value !== Unassigned;
|
||||
const organizationId$ = filter$.pipe(
|
||||
map((filter) => filter.organizationId),
|
||||
filter((filter) => filter !== undefined),
|
||||
filter(isOrganizationId),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
@@ -373,9 +383,12 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
this.allCollectionsWithoutUnassigned$,
|
||||
]).pipe(
|
||||
map(([organizationId, allCollections]) => {
|
||||
// FIXME: We should not assert that the Unassigned type is a CollectionId.
|
||||
// Instead we should consider representing the Unassigned collection as a different object, given that
|
||||
// it is not actually a collection.
|
||||
const noneCollection = new CollectionAdminView();
|
||||
noneCollection.name = this.i18nService.t("unassigned");
|
||||
noneCollection.id = Unassigned;
|
||||
noneCollection.id = Unassigned as CollectionId;
|
||||
noneCollection.organizationId = organizationId;
|
||||
return allCollections.concat(noneCollection);
|
||||
}),
|
||||
@@ -1420,6 +1433,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
|
||||
private refresh() {
|
||||
this.refresh$.next();
|
||||
this.vaultItemsComponent?.clearSelection();
|
||||
}
|
||||
|
||||
private go(queryParams: any = null) {
|
||||
|
||||
@@ -89,8 +89,8 @@ export class GroupsComponent {
|
||||
protected searchControl = new FormControl("");
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 52;
|
||||
protected rowHeightClass = `tw-h-[52px]`;
|
||||
protected rowHeight = 50;
|
||||
protected rowHeightClass = `tw-h-[50px]`;
|
||||
|
||||
protected ModalTabType = GroupAddEditTabType;
|
||||
private refreshGroups$ = new BehaviorSubject<void>(null);
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response";
|
||||
import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
@@ -23,11 +26,13 @@ import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserService } from "../../services/organization-user/organization-user.service";
|
||||
|
||||
import { BaseBulkConfirmComponent } from "./base-bulk-confirm.component";
|
||||
import { BulkUserDetails } from "./bulk-status.component";
|
||||
|
||||
type BulkConfirmDialogParams = {
|
||||
organizationId: string;
|
||||
organization: Organization;
|
||||
users: BulkUserDetails[];
|
||||
};
|
||||
|
||||
@@ -36,7 +41,7 @@ type BulkConfirmDialogParams = {
|
||||
standalone: false,
|
||||
})
|
||||
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
organizationId: string;
|
||||
organization: Organization;
|
||||
organizationKey$: Observable<OrgKey>;
|
||||
users: BulkUserDetails[];
|
||||
|
||||
@@ -47,13 +52,15 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
protected i18nService: I18nService,
|
||||
private stateProvider: StateProvider,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(keyService, encryptService, i18nService);
|
||||
|
||||
this.organizationId = dialogParams.organizationId;
|
||||
this.organization = dialogParams.organization;
|
||||
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
|
||||
map((organizationKeysById) => organizationKeysById[this.organization.id as OrganizationId]),
|
||||
takeUntilDestroyed(),
|
||||
);
|
||||
this.users = dialogParams.users;
|
||||
@@ -66,7 +73,7 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
ListResponse<OrganizationUserBulkPublicKeyResponse | ProviderUserBulkPublicKeyResponse>
|
||||
> =>
|
||||
await this.organizationUserApiService.postOrganizationUsersPublicKey(
|
||||
this.organizationId,
|
||||
this.organization.id,
|
||||
this.filteredUsers.map((user) => user.id),
|
||||
);
|
||||
|
||||
@@ -76,11 +83,19 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {
|
||||
protected postConfirmRequest = async (
|
||||
userIdsWithKeys: { id: string; key: string }[],
|
||||
): Promise<ListResponse<OrganizationUserBulkResponse | ProviderUserBulkResponse>> => {
|
||||
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
|
||||
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
this.organizationId,
|
||||
request,
|
||||
);
|
||||
if (
|
||||
await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation))
|
||||
) {
|
||||
return await firstValueFrom(
|
||||
this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys),
|
||||
);
|
||||
} else {
|
||||
const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys);
|
||||
return await this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
this.organization.id,
|
||||
request,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<BulkConfirmDialogParams>) {
|
||||
|
||||
@@ -111,8 +111,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
protected showUserManagementControls$: Observable<boolean>;
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 69;
|
||||
protected rowHeightClass = `tw-h-[69px]`;
|
||||
protected rowHeight = 66;
|
||||
protected rowHeightClass = `tw-h-[66px]`;
|
||||
|
||||
private organizationUsersCount = 0;
|
||||
|
||||
@@ -721,7 +721,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
organizationId: this.organization.id,
|
||||
organization: this.organization,
|
||||
users: this.dataSource.getCheckedUsers(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserConfirmRequest,
|
||||
OrganizationUserBulkConfirmRequest,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/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 { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationUserView } from "../../../core/views/organization-user.view";
|
||||
|
||||
import { OrganizationUserService } from "./organization-user.service";
|
||||
|
||||
describe("OrganizationUserService", () => {
|
||||
let service: OrganizationUserService;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
let accountService: jest.Mocked<AccountService>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
const mockOrganization = new Organization();
|
||||
mockOrganization.id = "org-123" as OrganizationId;
|
||||
|
||||
const mockOrganizationUser = new OrganizationUserView();
|
||||
mockOrganizationUser.id = "user-123";
|
||||
|
||||
const mockPublicKey = new Uint8Array(64) as CsprngArray;
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
|
||||
const mockEncryptedKey = { encryptedString: "encrypted-key" } as EncString;
|
||||
const mockEncryptedCollectionName = { encryptedString: "encrypted-collection-name" } as EncString;
|
||||
const mockDefaultCollectionName = "My Items";
|
||||
|
||||
const setupCommonMocks = () => {
|
||||
keyService.orgKeys$.mockReturnValue(
|
||||
of({ [mockOrganization.id]: mockOrgKey } as Record<OrganizationId, OrgKey>),
|
||||
);
|
||||
encryptService.encryptString.mockResolvedValue(mockEncryptedCollectionName);
|
||||
i18nService.t.mockReturnValue(mockDefaultCollectionName);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = {
|
||||
orgKeys$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
encryptService = {
|
||||
encryptString: jest.fn(),
|
||||
encapsulateKeyUnsigned: jest.fn(),
|
||||
} as any;
|
||||
|
||||
organizationUserApiService = {
|
||||
postOrganizationUserConfirm: jest.fn(),
|
||||
postOrganizationUserBulkConfirm: jest.fn(),
|
||||
} as any;
|
||||
|
||||
accountService = {
|
||||
activeAccount$: of({ id: "user-123" }),
|
||||
} as any;
|
||||
|
||||
i18nService = {
|
||||
t: jest.fn(),
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OrganizationUserService,
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: EncryptService, useValue: encryptService },
|
||||
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(OrganizationUserService);
|
||||
});
|
||||
|
||||
describe("confirmUser", () => {
|
||||
beforeEach(() => {
|
||||
setupCommonMocks();
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey);
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockReturnValue(Promise.resolve());
|
||||
});
|
||||
|
||||
it("should confirm a user successfully", (done) => {
|
||||
service.confirmUser(mockOrganization, mockOrganizationUser, mockPublicKey).subscribe({
|
||||
next: () => {
|
||||
expect(i18nService.t).toHaveBeenCalledWith("myItems");
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
mockDefaultCollectionName,
|
||||
mockOrgKey,
|
||||
);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
mockOrgKey,
|
||||
mockPublicKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
mockOrganizationUser.id,
|
||||
{
|
||||
key: mockEncryptedKey.encryptedString,
|
||||
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
|
||||
} as OrganizationUserConfirmRequest,
|
||||
);
|
||||
|
||||
done();
|
||||
},
|
||||
error: done,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkConfirmUsers", () => {
|
||||
const mockUserIdsWithKeys = [
|
||||
{ id: "user-1", key: "key-1" },
|
||||
{ id: "user-2", key: "key-2" },
|
||||
];
|
||||
|
||||
const mockBulkResponse = {
|
||||
data: [
|
||||
{ id: "user-1", error: null } as OrganizationUserBulkResponse,
|
||||
{ id: "user-2", error: null } as OrganizationUserBulkResponse,
|
||||
],
|
||||
} as ListResponse<OrganizationUserBulkResponse>;
|
||||
|
||||
beforeEach(() => {
|
||||
setupCommonMocks();
|
||||
organizationUserApiService.postOrganizationUserBulkConfirm.mockReturnValue(
|
||||
Promise.resolve(mockBulkResponse),
|
||||
);
|
||||
});
|
||||
|
||||
it("should bulk confirm users successfully", (done) => {
|
||||
service.bulkConfirmUsers(mockOrganization, mockUserIdsWithKeys).subscribe({
|
||||
next: (response) => {
|
||||
expect(i18nService.t).toHaveBeenCalledWith("myItems");
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
mockDefaultCollectionName,
|
||||
mockOrgKey,
|
||||
);
|
||||
|
||||
expect(organizationUserApiService.postOrganizationUserBulkConfirm).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
new OrganizationUserBulkConfirmRequest(
|
||||
mockUserIdsWithKeys,
|
||||
mockEncryptedCollectionName.encryptedString,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response).toEqual(mockBulkResponse);
|
||||
|
||||
done();
|
||||
},
|
||||
error: done,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserConfirmRequest,
|
||||
OrganizationUserBulkConfirmRequest,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserBulkResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
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 { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -41,11 +44,7 @@ export class OrganizationUserService {
|
||||
user: OrganizationUserView,
|
||||
publicKey: Uint8Array,
|
||||
): Observable<void> {
|
||||
const encryptedCollectionName$ = this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) =>
|
||||
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
|
||||
),
|
||||
);
|
||||
const encryptedCollectionName$ = this.getEncryptedDefaultCollectionName$(organization);
|
||||
|
||||
const encryptedKey$ = this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)),
|
||||
@@ -66,4 +65,31 @@ export class OrganizationUserService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bulkConfirmUsers(
|
||||
organization: Organization,
|
||||
userIdsWithKeys: { id: string; key: string }[],
|
||||
): Observable<ListResponse<OrganizationUserBulkResponse>> {
|
||||
return this.getEncryptedDefaultCollectionName$(organization).pipe(
|
||||
switchMap((collectionName) => {
|
||||
const request = new OrganizationUserBulkConfirmRequest(
|
||||
userIdsWithKeys,
|
||||
collectionName.encryptedString,
|
||||
);
|
||||
|
||||
return this.organizationUserApiService.postOrganizationUserBulkConfirm(
|
||||
organization.id,
|
||||
request,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getEncryptedDefaultCollectionName$(organization: Organization) {
|
||||
return this.orgKey$(organization).pipe(
|
||||
switchMap((orgKey) =>
|
||||
this.encryptService.encryptString(this.i18nService.t("myItems"), orgKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, Input, OnInit } from "@angular/core";
|
||||
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
export abstract class BasePolicy {
|
||||
abstract name: string;
|
||||
@@ -14,38 +14,56 @@ export abstract class BasePolicy {
|
||||
abstract type: PolicyType;
|
||||
abstract component: any;
|
||||
|
||||
display(organization: Organization) {
|
||||
return true;
|
||||
/**
|
||||
* If true, the description will be reused in the policy edit modal. Set this to false if you
|
||||
* have more complex requirements that you will implement in your template instead.
|
||||
**/
|
||||
showDescription: boolean = true;
|
||||
|
||||
display(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Directive()
|
||||
export abstract class BasePolicyComponent implements OnInit {
|
||||
@Input() policyResponse: PolicyResponse;
|
||||
@Input() policy: BasePolicy;
|
||||
@Input() policyResponse: PolicyResponse | undefined;
|
||||
@Input() policy: BasePolicy | undefined;
|
||||
|
||||
enabled = new UntypedFormControl(false);
|
||||
data: UntypedFormGroup = null;
|
||||
data: UntypedFormGroup | undefined;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.enabled.setValue(this.policyResponse.enabled);
|
||||
this.enabled.setValue(this.policyResponse?.enabled);
|
||||
|
||||
if (this.policyResponse.data != null) {
|
||||
if (this.policyResponse?.data != null) {
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
buildRequest() {
|
||||
const request = new PolicyRequest();
|
||||
request.enabled = this.enabled.value;
|
||||
request.type = this.policy.type;
|
||||
request.data = this.buildRequestData();
|
||||
if (!this.policy) {
|
||||
throw new Error("Policy was not found");
|
||||
}
|
||||
|
||||
const request: PolicyRequest = {
|
||||
type: this.policy.type,
|
||||
enabled: this.enabled.value,
|
||||
data: this.buildRequestData(),
|
||||
};
|
||||
|
||||
return Promise.resolve(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable optional validation before sumitting a respose for policy submission
|
||||
* */
|
||||
confirm(): Promise<boolean> | boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected loadData() {
|
||||
this.data.patchValue(this.policyResponse.data ?? {});
|
||||
this.data?.patchValue(this.policyResponse?.data ?? {});
|
||||
}
|
||||
|
||||
protected buildRequestData() {
|
||||
|
||||
@@ -3,6 +3,7 @@ export { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
export { DisableSendPolicy } from "./disable-send.component";
|
||||
export { MasterPasswordPolicy } from "./master-password.component";
|
||||
export { PasswordGeneratorPolicy } from "./password-generator.component";
|
||||
export { vNextOrganizationDataOwnershipPolicy } from "./vnext-organization-data-ownership.component";
|
||||
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
|
||||
export { RequireSsoPolicy } from "./require-sso.component";
|
||||
export { ResetPasswordPolicy } from "./reset-password.component";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
|
||||
@@ -9,6 +13,12 @@ export class OrganizationDataOwnershipPolicy extends BasePolicy {
|
||||
description = "personalOwnershipPolicyDesc";
|
||||
type = PolicyType.OrganizationDataOwnership;
|
||||
component = OrganizationDataOwnershipPolicyComponent;
|
||||
|
||||
display(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return configService
|
||||
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
|
||||
.pipe(map((enabled) => !enabled));
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
<app-header>
|
||||
@let organization = organization$ | async;
|
||||
<button
|
||||
bitBadge
|
||||
class="!tw-align-middle"
|
||||
(click)="changePlan(organization)"
|
||||
*ngIf="isBreadcrumbingEnabled$ | async"
|
||||
slot="title-suffix"
|
||||
type="button"
|
||||
variant="primary"
|
||||
>
|
||||
{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
@if (isBreadcrumbingEnabled$ | async) {
|
||||
<button
|
||||
bitBadge
|
||||
class="!tw-align-middle"
|
||||
(click)="changePlan(organization)"
|
||||
slot="title-suffix"
|
||||
type="button"
|
||||
variant="primary"
|
||||
>
|
||||
{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</app-header>
|
||||
|
||||
<bit-container>
|
||||
<ng-container *ngIf="loading">
|
||||
@if (loading) {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</ng-container>
|
||||
<bit-table *ngIf="!loading">
|
||||
<ng-template body>
|
||||
<tr bitRow *ngFor="let p of policies">
|
||||
<td bitCell *ngIf="p.display(organization)" ngPreserveWhitespaces>
|
||||
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
|
||||
<span bitBadge variant="success" *ngIf="policiesEnabledMap.get(p.type)">{{
|
||||
"on" | i18n
|
||||
}}</span>
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
}
|
||||
@if (!loading) {
|
||||
<bit-table>
|
||||
<ng-template body>
|
||||
@for (p of policies; track p.name) {
|
||||
@if (p.display(organization, configService) | async) {
|
||||
<tr bitRow>
|
||||
<td bitCell ngPreserveWhitespaces>
|
||||
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
|
||||
@if (policiesEnabledMap.get(p.type)) {
|
||||
<span bitBadge variant="success">{{ "on" | i18n }}</span>
|
||||
}
|
||||
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
}
|
||||
</bit-container>
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||
|
||||
import { PolicyListService } from "../../core/policy-list.service";
|
||||
import { BasePolicy, RestrictedItemTypesPolicy } from "../policies";
|
||||
import { BasePolicy } from "../policies";
|
||||
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
|
||||
|
||||
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
|
||||
@@ -53,7 +52,7 @@ export class PoliciesComponent implements OnInit {
|
||||
private policyListService: PolicyListService,
|
||||
private organizationBillingService: OrganizationBillingServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
protected configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -71,35 +70,31 @@ export class PoliciesComponent implements OnInit {
|
||||
await this.load();
|
||||
|
||||
// Handle policies component launch from Event message
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
|
||||
if (qParams.policyId != null) {
|
||||
const policyIdFromEvents: string = qParams.policyId;
|
||||
for (const orgPolicy of this.orgPolicies) {
|
||||
if (orgPolicy.id === policyIdFromEvents) {
|
||||
for (let i = 0; i < this.policies.length; i++) {
|
||||
if (this.policies[i].type === orgPolicy.type) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.edit(this.policies[i]);
|
||||
break;
|
||||
this.route.queryParams
|
||||
.pipe(first())
|
||||
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
|
||||
.subscribe(async (qParams) => {
|
||||
if (qParams.policyId != null) {
|
||||
const policyIdFromEvents: string = qParams.policyId;
|
||||
for (const orgPolicy of this.orgPolicies) {
|
||||
if (orgPolicy.id === policyIdFromEvents) {
|
||||
for (let i = 0; i < this.policies.length; i++) {
|
||||
if (this.policies[i].type === orgPolicy.type) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.edit(this.policies[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (
|
||||
(await this.configService.getFeatureFlag(FeatureFlag.RemoveCardItemTypePolicy)) &&
|
||||
this.policyListService.getPolicies().every((p) => !(p instanceof RestrictedItemTypesPolicy))
|
||||
) {
|
||||
this.policyListService.addPolicies([new RestrictedItemTypesPolicy()]);
|
||||
}
|
||||
const response = await this.policyApiService.getPolicies(this.organizationId);
|
||||
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
|
||||
this.orgPolicies.forEach((op) => {
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<div [hidden]="loading">
|
||||
<p bitTypography="body1">{{ policy.description | i18n }}</p>
|
||||
@if (policy.showDescription) {
|
||||
<p bitTypography="body1">{{ policy.description | i18n }}</p>
|
||||
}
|
||||
<ng-template #policyForm></ng-template>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
if ((await this.policyComponent.confirm()) == false) {
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
|
||||
let request: PolicyRequest;
|
||||
|
||||
try {
|
||||
request = await this.policyComponent.buildRequest();
|
||||
} catch (e) {
|
||||
this.toastService.showToast({ variant: "error", title: null, message: e.message });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
|
||||
@@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy {
|
||||
type = PolicyType.RequireSso;
|
||||
component = RequireSsoPolicyComponent;
|
||||
|
||||
display(organization: Organization) {
|
||||
return organization.useSso;
|
||||
display(organization: Organization, configService: ConfigService) {
|
||||
return of(organization.useSso);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
getOrganizationById,
|
||||
@@ -10,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
|
||||
@@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy {
|
||||
type = PolicyType.ResetPassword;
|
||||
component = ResetPasswordPolicyComponent;
|
||||
|
||||
display(organization: Organization) {
|
||||
return organization.useResetPassword;
|
||||
display(organization: Organization, configService: ConfigService) {
|
||||
return of(organization.useResetPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements
|
||||
throw new Error("No user found.");
|
||||
}
|
||||
|
||||
if (!this.policyResponse) {
|
||||
throw new Error("Policies not found");
|
||||
}
|
||||
|
||||
const organization = await firstValueFrom(
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
|
||||
@@ -9,6 +13,10 @@ export class RestrictedItemTypesPolicy extends BasePolicy {
|
||||
description = "restrictedItemTypePolicyDesc";
|
||||
type = PolicyType.RestrictedItemTypes;
|
||||
component = RestrictedItemTypesPolicyComponent;
|
||||
|
||||
display(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI
|
||||
async ngOnInit() {
|
||||
super.ngOnInit();
|
||||
|
||||
if (!this.policyResponse) {
|
||||
throw new Error("Policies not found");
|
||||
}
|
||||
if (!this.policyResponse.canToggleState) {
|
||||
this.enabled.disable();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<p>
|
||||
{{ "organizationDataOwnershipContent" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
|
||||
<bit-label>{{ "turnOn" | i18n }}</bit-label>
|
||||
</bit-form-control>
|
||||
|
||||
<ng-template #dialog>
|
||||
<bit-simple-dialog background="alt">
|
||||
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<div class="tw-text-left tw-overflow-hidden">
|
||||
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
|
||||
<div class="tw-flex tw-flex-col tw-p-2">
|
||||
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning1" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning2" | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
{{ "organizationDataOwnershipWarning3" | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/resources/credential-lifecycle-management/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<span class="tw-flex tw-gap-2">
|
||||
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
|
||||
{{ "continue" | i18n }}
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { lastValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
|
||||
|
||||
export class vNextOrganizationDataOwnershipPolicy extends BasePolicy {
|
||||
name = "organizationDataOwnership";
|
||||
description = "organizationDataOwnershipDesc";
|
||||
type = PolicyType.OrganizationDataOwnership;
|
||||
component = vNextOrganizationDataOwnershipPolicyComponent;
|
||||
showDescription = false;
|
||||
|
||||
override display(organization: Organization, configService: ConfigService): Observable<boolean> {
|
||||
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "vnext-policy-organization-data-ownership",
|
||||
templateUrl: "vnext-organization-data-ownership.component.html",
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
})
|
||||
export class vNextOrganizationDataOwnershipPolicyComponent
|
||||
extends BasePolicyComponent
|
||||
implements OnInit
|
||||
{
|
||||
constructor(private dialogService: DialogService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
|
||||
|
||||
override async confirm(): Promise<boolean> {
|
||||
if (this.policyResponse?.enabled && !this.enabled.value) {
|
||||
const dialogRef = this.dialogService.open(this.warningContent);
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
return Boolean(result);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogConfig,
|
||||
@@ -87,8 +88,8 @@ enum ButtonType {
|
||||
}
|
||||
|
||||
export interface CollectionDialogParams {
|
||||
collectionId?: string;
|
||||
organizationId: string;
|
||||
collectionId?: CollectionId;
|
||||
organizationId: OrganizationId;
|
||||
initialTab?: CollectionDialogTabType;
|
||||
parentCollectionId?: string;
|
||||
showOrgSelector?: boolean;
|
||||
@@ -136,7 +137,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
|
||||
externalId: { value: "", disabled: true },
|
||||
parent: undefined as string | undefined,
|
||||
access: [[] as AccessItemValue[]],
|
||||
selectedOrg: "",
|
||||
selectedOrg: "" as OrganizationId,
|
||||
});
|
||||
protected PermissionMode = PermissionMode;
|
||||
protected showDeleteButton = false;
|
||||
|
||||
@@ -35,12 +35,14 @@ import {
|
||||
MasterPasswordPolicy,
|
||||
PasswordGeneratorPolicy,
|
||||
OrganizationDataOwnershipPolicy,
|
||||
vNextOrganizationDataOwnershipPolicy,
|
||||
RequireSsoPolicy,
|
||||
ResetPasswordPolicy,
|
||||
SendOptionsPolicy,
|
||||
SingleOrgPolicy,
|
||||
TwoFactorAuthenticationPolicy,
|
||||
RemoveUnlockWithPinPolicy,
|
||||
RestrictedItemTypesPolicy,
|
||||
} from "./admin-console/organizations/policies";
|
||||
|
||||
const BroadcasterSubscriptionId = "AppComponent";
|
||||
@@ -244,8 +246,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
new SingleOrgPolicy(),
|
||||
new RequireSsoPolicy(),
|
||||
new OrganizationDataOwnershipPolicy(),
|
||||
new vNextOrganizationDataOwnershipPolicy(),
|
||||
new DisableSendPolicy(),
|
||||
new SendOptionsPolicy(),
|
||||
new RestrictedItemTypesPolicy(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { MockProxy } from "jest-mock-extended";
|
||||
import mock from "jest-mock-extended/lib/Mock";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -14,9 +15,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { KdfType, KeyService } from "@bitwarden/key-management";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
EmergencyAccessGranteeDetailsResponse,
|
||||
EmergencyAccessGrantorDetailsResponse,
|
||||
EmergencyAccessTakeoverResponse,
|
||||
EmergencyAccessViewResponse,
|
||||
} from "../response/emergency-access.response";
|
||||
|
||||
import { EmergencyAccessApiService } from "./emergency-access-api.service";
|
||||
@@ -142,88 +144,306 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getViewOnlyCiphers", () => {
|
||||
const params = {
|
||||
id: "emergency-access-id",
|
||||
activeUserId: Utils.newGuid() as UserId,
|
||||
};
|
||||
|
||||
it("throws an error is the active user's private key isn't available", async () => {
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.getViewOnlyCiphers(params.id, params.activeUserId),
|
||||
).rejects.toThrow("Active user does not have a private key, cannot get view only ciphers.");
|
||||
});
|
||||
|
||||
it("should return decrypted and sorted ciphers", async () => {
|
||||
const emergencyAccessViewResponse = {
|
||||
keyEncrypted: "mockKeyEncrypted",
|
||||
ciphers: [
|
||||
{ id: "cipher1", name: "encryptedName1" },
|
||||
{ id: "cipher2", name: "encryptedName2" },
|
||||
],
|
||||
} as EmergencyAccessViewResponse;
|
||||
|
||||
const mockEncryptedCipher1 = {
|
||||
id: "cipher1",
|
||||
decrypt: jest.fn().mockResolvedValue({ id: "cipher1", decrypted: true }),
|
||||
};
|
||||
const mockEncryptedCipher2 = {
|
||||
id: "cipher2",
|
||||
decrypt: jest.fn().mockResolvedValue({ id: "cipher2", decrypted: true }),
|
||||
};
|
||||
emergencyAccessViewResponse.ciphers.map = jest.fn().mockImplementation(() => {
|
||||
return [mockEncryptedCipher1, mockEncryptedCipher2];
|
||||
});
|
||||
cipherService.getLocaleSortingFunction.mockReturnValue((a: any, b: any) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
emergencyAccessApiService.postEmergencyAccessView.mockResolvedValue(
|
||||
emergencyAccessViewResponse,
|
||||
);
|
||||
|
||||
const mockPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
keyService.userPrivateKey$.mockReturnValue(of(mockPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
|
||||
const mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
const result = await emergencyAccessService.getViewOnlyCiphers(
|
||||
params.id,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "cipher1", decrypted: true },
|
||||
{ id: "cipher2", decrypted: true },
|
||||
]);
|
||||
expect(mockEncryptedCipher1.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
|
||||
expect(mockEncryptedCipher2.decrypt).toHaveBeenCalledWith(mockGrantorUserKey);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessView).toHaveBeenCalledWith(params.id);
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(emergencyAccessViewResponse.keyEncrypted),
|
||||
mockPrivateKey,
|
||||
);
|
||||
expect(cipherService.getLocaleSortingFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover", () => {
|
||||
const mockId = "emergencyAccessId";
|
||||
const mockEmail = "emergencyAccessEmail";
|
||||
const mockName = "emergencyAccessName";
|
||||
const params = {
|
||||
id: "emergencyAccessId",
|
||||
masterPassword: "mockPassword",
|
||||
email: "emergencyAccessEmail",
|
||||
activeUserId: Utils.newGuid() as UserId,
|
||||
};
|
||||
|
||||
const takeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
const userPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
|
||||
const mockMasterKeyHash = "mockMasterKeyHash";
|
||||
let mockGrantorUserKey: UserKey;
|
||||
|
||||
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
|
||||
// where UserKey is the decrypted grantor user key
|
||||
const mockMasterKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"mockMasterKeyEncryptedUserKey",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(mockDecryptedGrantorUserKey);
|
||||
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
|
||||
mockGrantorUserKey,
|
||||
mockMasterKeyEncryptedUserKey,
|
||||
]);
|
||||
});
|
||||
|
||||
it("posts a new password when decryption succeeds", async () => {
|
||||
// Arrange
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
|
||||
const mockDecryptedGrantorUserKey = new Uint8Array(64);
|
||||
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(
|
||||
new SymmetricCryptoKey(mockDecryptedGrantorUserKey),
|
||||
);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey;
|
||||
|
||||
keyService.makeMasterKey.mockResolvedValueOnce(mockMasterKey);
|
||||
|
||||
const mockMasterKeyHash = "mockMasterKeyHash";
|
||||
keyService.hashMasterKey.mockResolvedValueOnce(mockMasterKeyHash);
|
||||
|
||||
// must mock [UserKey, EncString] return from keyService.encryptUserKeyWithMasterKey
|
||||
// where UserKey is the decrypted grantor user key
|
||||
const mockMasterKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
"mockMasterKeyEncryptedUserKey",
|
||||
);
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(mockDecryptedGrantorUserKey) as UserKey;
|
||||
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce([
|
||||
mockUserKey,
|
||||
mockMasterKeyEncryptedUserKey,
|
||||
]);
|
||||
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
|
||||
|
||||
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
|
||||
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
|
||||
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
// Act
|
||||
await emergencyAccessService.takeover(mockId, mockEmail, mockName);
|
||||
await emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
mockId,
|
||||
params.id,
|
||||
expectedEmergencyAccessPasswordRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption fails", async () => {
|
||||
encryptService.rsaDecrypt.mockResolvedValueOnce(null);
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
it("uses argon2 KDF if takeover response is argon2", async () => {
|
||||
const argon2TakeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
keyService.getPrivateKey.mockResolvedValue(new Uint8Array(64));
|
||||
kdf: KdfType.Argon2id,
|
||||
kdfIterations: 3,
|
||||
kdfMemory: 64,
|
||||
kdfParallelism: 4,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockReset();
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(
|
||||
argon2TakeoverResponse,
|
||||
);
|
||||
|
||||
const expectedKdfConfig = new Argon2KdfConfig(
|
||||
argon2TakeoverResponse.kdfIterations,
|
||||
argon2TakeoverResponse.kdfMemory,
|
||||
argon2TakeoverResponse.kdfParallelism,
|
||||
);
|
||||
|
||||
const expectedEmergencyAccessPasswordRequest = new EmergencyAccessPasswordRequest();
|
||||
expectedEmergencyAccessPasswordRequest.newMasterPasswordHash = mockMasterKeyHash;
|
||||
expectedEmergencyAccessPasswordRequest.key = mockMasterKeyEncryptedUserKey.encryptedString;
|
||||
|
||||
await emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
);
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(argon2TakeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
params.id,
|
||||
expectedEmergencyAccessPasswordRequest,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an error if masterKeyEncryptedUserKey is not found", async () => {
|
||||
keyService.encryptUserKeyWithMasterKey.mockReset();
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValueOnce(null);
|
||||
const expectedKdfConfig = new PBKDF2KdfConfig(takeoverResponse.kdfIterations);
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(mockId, mockEmail, mockName),
|
||||
).rejects.toThrowError("Failed to decrypt grantor key");
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("masterKeyEncryptedUserKey not found");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
expectedKdfConfig,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalledWith(params.masterPassword, mockMasterKey);
|
||||
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterKey,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption fails", async () => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
encryptService.decapsulateKeyUnsigned.mockReset();
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("Failed to decrypt grantor key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not post a new password if decryption throws", async () => {
|
||||
encryptService.decapsulateKeyUnsigned.mockReset();
|
||||
encryptService.decapsulateKeyUnsigned.mockImplementationOnce(() => {
|
||||
throw new Error("Failed to unwrap grantor key");
|
||||
});
|
||||
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrowError("Failed to unwrap grantor key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
new EncString(takeoverResponse.keyEncrypted),
|
||||
userPrivateKey,
|
||||
);
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw an error if the users private key cannot be retrieved", async () => {
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce({
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 500,
|
||||
} as EmergencyAccessTakeoverResponse);
|
||||
keyService.getPrivateKey.mockResolvedValue(null);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(emergencyAccessService.takeover(mockId, mockEmail, mockName)).rejects.toThrow(
|
||||
"user does not have a private key",
|
||||
);
|
||||
await expect(
|
||||
emergencyAccessService.takeover(
|
||||
params.id,
|
||||
params.masterPassword,
|
||||
params.email,
|
||||
params.activeUserId,
|
||||
),
|
||||
).rejects.toThrow("user does not have a private key");
|
||||
|
||||
expect(keyService.userPrivateKey$).toHaveBeenCalledWith(params.activeUserId);
|
||||
expect(encryptService.decapsulateKeyUnsigned).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
@@ -237,11 +238,14 @@ export class EmergencyAccessService
|
||||
* Gets the grantor ciphers for an emergency access in view mode.
|
||||
* Intended for grantee.
|
||||
* @param id emergency access id
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async getViewOnlyCiphers(id: string): Promise<CipherView[]> {
|
||||
async getViewOnlyCiphers(id: string, activeUserId: UserId): Promise<CipherView[]> {
|
||||
const response = await this.emergencyAccessApiService.postEmergencyAccessView(id);
|
||||
|
||||
const activeUserPrivateKey = await this.keyService.getPrivateKey();
|
||||
const activeUserPrivateKey = await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(activeUserId),
|
||||
);
|
||||
|
||||
if (activeUserPrivateKey == null) {
|
||||
throw new Error("Active user does not have a private key, cannot get view only ciphers.");
|
||||
@@ -264,11 +268,14 @@ export class EmergencyAccessService
|
||||
* @param id emergency access id
|
||||
* @param masterPassword new master password
|
||||
* @param email email address of grantee (must be consistent or login will fail)
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async takeover(id: string, masterPassword: string, email: string) {
|
||||
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
|
||||
const takeoverResponse = await this.emergencyAccessApiService.postEmergencyAccessTakeover(id);
|
||||
|
||||
const activeUserPrivateKey = await this.keyService.getPrivateKey();
|
||||
const activeUserPrivateKey = await firstValueFrom(
|
||||
this.keyService.userPrivateKey$(activeUserId),
|
||||
);
|
||||
|
||||
if (activeUserPrivateKey == null) {
|
||||
throw new Error("Active user does not have a private key, cannot complete a takeover.");
|
||||
@@ -312,9 +319,7 @@ export class EmergencyAccessService
|
||||
request.newMasterPasswordHash = masterKeyHash;
|
||||
request.key = encKey[1].encryptedString;
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
}
|
||||
|
||||
private async getEmergencyAccessData(): Promise<EmergencyAccessGranteeDetailsResponse[]> {
|
||||
|
||||
@@ -115,10 +115,12 @@ export class EmergencyAccessTakeoverDialogComponent implements OnInit {
|
||||
this.parentSubmittingBehaviorSubject.next(true);
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.emergencyAccessService.takeover(
|
||||
this.dialogData.emergencyAccessId,
|
||||
passwordInputResult.newPassword,
|
||||
this.dialogData.grantorEmail,
|
||||
activeUserId,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EmergencyAccessId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
@@ -27,6 +29,7 @@ export class EmergencyAccessViewComponent implements OnInit {
|
||||
private route: ActivatedRoute,
|
||||
private emergencyAccessService: EmergencyAccessService,
|
||||
private dialogService: DialogService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -37,7 +40,8 @@ export class EmergencyAccessViewComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.id = qParams.id;
|
||||
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id);
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.ciphers = await this.emergencyAccessService.getViewOnlyCiphers(qParams.id, userId);
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, DestroyRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
|
||||
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import {
|
||||
@@ -325,7 +325,7 @@ export class DeviceManagementOldComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = LoginApprovalComponent.open(this.dialogService, {
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: device.devicePendingAuthRequest.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Guid } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class RequestSMAccessRequest {
|
||||
OrganizationId: Guid;
|
||||
OrganizationId: OrganizationId;
|
||||
EmailContent: string;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Guid } from "@bitwarden/common/types/guid";
|
||||
import { NoItemsModule, SearchModule, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
@@ -63,7 +62,7 @@ export class RequestSMAccessComponent implements OnInit {
|
||||
|
||||
const formValue = this.requestAccessForm.value;
|
||||
const request = new RequestSMAccessRequest();
|
||||
request.OrganizationId = formValue.selectedOrganization.id as Guid;
|
||||
request.OrganizationId = formValue.selectedOrganization.id;
|
||||
request.EmailContent = formValue.requestAccessEmailContents;
|
||||
|
||||
await this.smLandingApiService.requestSMAccessFromAdmins(request);
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
buttonType="secondary"
|
||||
[bitMenuTriggerFor]="appListDropdown"
|
||||
class="tw-border-0 tw-bg-transparent tw-p-0"
|
||||
></button>
|
||||
|
||||
@@ -26,14 +26,7 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="pageState === BrowserPromptState.ManualOpen">
|
||||
<p bitTypography="body1" class="tw-mb-0 tw-text-xl">
|
||||
{{ "openExtensionManuallyPart1" | i18n }}
|
||||
<bit-icon
|
||||
[icon]="BitwardenIcon"
|
||||
class="[&>svg]:tw-align-baseline [&>svg]:-tw-mb-[2px]"
|
||||
></bit-icon>
|
||||
{{ "openExtensionManuallyPart2" | i18n }}
|
||||
</p>
|
||||
<vault-manually-open-extension></vault-manually-open-extension>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="pageState === BrowserPromptState.MobileBrowser">
|
||||
|
||||
@@ -3,17 +3,17 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
|
||||
import { ButtonComponent, IconModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import {
|
||||
BrowserExtensionPromptService,
|
||||
BrowserPromptState,
|
||||
} from "../../services/browser-extension-prompt.service";
|
||||
import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
|
||||
|
||||
@Component({
|
||||
selector: "vault-browser-extension-prompt",
|
||||
templateUrl: "./browser-extension-prompt.component.html",
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule],
|
||||
imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent],
|
||||
})
|
||||
export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
|
||||
/** Current state of the prompt page */
|
||||
@@ -22,8 +22,6 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
|
||||
/** All available page states */
|
||||
protected BrowserPromptState = BrowserPromptState;
|
||||
|
||||
protected BitwardenIcon = VaultIcons.BitwardenIcon;
|
||||
|
||||
/** Content of the meta[name="viewport"] element */
|
||||
private viewportContent: string | null = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<p bitTypography="body1" class="tw-mb-0 tw-text-xl">
|
||||
{{ "openExtensionManuallyPart1" | i18n }}
|
||||
<bit-icon
|
||||
[icon]="BitwardenIcon"
|
||||
class="[&>svg]:tw-align-baseline [&>svg]:-tw-mb-[2px]"
|
||||
></bit-icon>
|
||||
{{ "openExtensionManuallyPart2" | i18n }}
|
||||
</p>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "vault-manually-open-extension",
|
||||
templateUrl: "./manually-open-extension.component.html",
|
||||
imports: [I18nPipe, IconModule],
|
||||
})
|
||||
export class ManuallyOpenExtensionComponent {
|
||||
protected BitwardenIcon = VaultIcons.BitwardenIcon;
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export class AddExtensionVideosComponent {
|
||||
/** CSS classes for the video container, pulled into the class only for readability. */
|
||||
protected videoContainerClass = [
|
||||
"tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]",
|
||||
`[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
|
||||
`[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
|
||||
`[--overlay-opacity:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`,
|
||||
`[--border-opacity:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`,
|
||||
"after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear",
|
||||
"before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear",
|
||||
].join(" ");
|
||||
|
||||
@@ -54,3 +54,7 @@
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section *ngIf="state === SetupExtensionState.ManualOpen" aria-live="polite" class="tw-text-center">
|
||||
<vault-manually-open-extension></vault-manually-open-extension>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
@@ -11,10 +11,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { AnonLayoutWrapperDataService } from "@bitwarden/components";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
|
||||
import { SetupExtensionComponent } from "./setup-extension.component";
|
||||
import { SetupExtensionComponent, SetupExtensionState } from "./setup-extension.component";
|
||||
|
||||
describe("SetupExtensionComponent", () => {
|
||||
let fixture: ComponentFixture<SetupExtensionComponent>;
|
||||
@@ -24,12 +26,14 @@ describe("SetupExtensionComponent", () => {
|
||||
const navigate = jest.fn().mockResolvedValue(true);
|
||||
const openExtension = jest.fn().mockResolvedValue(true);
|
||||
const update = jest.fn().mockResolvedValue(true);
|
||||
const setAnonLayoutWrapperData = jest.fn();
|
||||
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
navigate.mockClear();
|
||||
openExtension.mockClear();
|
||||
update.mockClear();
|
||||
setAnonLayoutWrapperData.mockClear();
|
||||
getFeatureFlag.mockClear().mockResolvedValue(true);
|
||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||
|
||||
@@ -40,6 +44,7 @@ describe("SetupExtensionComponent", () => {
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
|
||||
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: { setAnonLayoutWrapperData } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
|
||||
@@ -136,6 +141,27 @@ describe("SetupExtensionComponent", () => {
|
||||
it("dismisses the extension page", () => {
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error state when extension fails to open", fakeAsync(() => {
|
||||
openExtension.mockRejectedValueOnce(new Error("Failed to open extension"));
|
||||
|
||||
const openExtensionButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
openExtensionButton.triggerEventHandler("click");
|
||||
|
||||
tick();
|
||||
|
||||
expect(component["state"]).toBe(SetupExtensionState.ManualOpen);
|
||||
expect(setAnonLayoutWrapperData).toHaveBeenCalledWith({
|
||||
pageTitle: {
|
||||
key: "somethingWentWrong",
|
||||
},
|
||||
pageIcon: VaultIcons.BrowserExtensionIcon,
|
||||
hideIcon: false,
|
||||
hideCardWrapper: false,
|
||||
maxWidth: "md",
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
||||
import {
|
||||
AnonLayoutWrapperDataService,
|
||||
ButtonComponent,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
@@ -25,6 +26,7 @@ import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manually-open-extension.component";
|
||||
|
||||
import {
|
||||
AddExtensionLaterDialogComponent,
|
||||
@@ -32,10 +34,11 @@ import {
|
||||
} from "./add-extension-later-dialog.component";
|
||||
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||
|
||||
const SetupExtensionState = {
|
||||
export const SetupExtensionState = {
|
||||
Loading: "loading",
|
||||
NeedsExtension: "needs-extension",
|
||||
Success: "success",
|
||||
ManualOpen: "manual-open",
|
||||
} as const;
|
||||
|
||||
type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
@@ -51,6 +54,7 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
IconModule,
|
||||
RouterModule,
|
||||
AddExtensionVideosComponent,
|
||||
ManuallyOpenExtensionComponent,
|
||||
],
|
||||
})
|
||||
export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
@@ -63,6 +67,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
private stateProvider = inject(StateProvider);
|
||||
private accountService = inject(AccountService);
|
||||
private document = inject(DOCUMENT);
|
||||
private anonLayoutWrapperDataService = inject(AnonLayoutWrapperDataService);
|
||||
|
||||
protected SetupExtensionState = SetupExtensionState;
|
||||
protected PartyIcon = VaultIcons.Party;
|
||||
@@ -153,8 +158,21 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Opens the browser extension */
|
||||
openExtension() {
|
||||
void this.webBrowserExtensionInteractionService.openExtension();
|
||||
async openExtension() {
|
||||
await this.webBrowserExtensionInteractionService.openExtension().catch(() => {
|
||||
this.state = SetupExtensionState.ManualOpen;
|
||||
|
||||
// Update the anon layout data to show the proper error design
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
||||
pageTitle: {
|
||||
key: "somethingWentWrong",
|
||||
},
|
||||
pageIcon: VaultIcons.BrowserExtensionIcon,
|
||||
hideIcon: false,
|
||||
hideCardWrapper: false,
|
||||
maxWidth: "md",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Update local state to never show this page again. */
|
||||
|
||||
@@ -93,73 +93,74 @@
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
<button
|
||||
*ngIf="!decryptionFailure"
|
||||
[disabled]="disabled || disableMenu"
|
||||
[bitMenuTriggerFor]="cipherOptions"
|
||||
size="small"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
type="button"
|
||||
appA11yTitle="{{ (disableMenu ? 'missingPermissions' : 'options') | i18n }}"
|
||||
appStopProp
|
||||
></button>
|
||||
<bit-menu #cipherOptions>
|
||||
<ng-container *ngIf="isNotDeletedLoginCipher">
|
||||
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyUsername" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyPassword" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyVerificationCode" | i18n }}
|
||||
</button>
|
||||
<a
|
||||
bitMenuItem
|
||||
*ngIf="canLaunch"
|
||||
type="button"
|
||||
[href]="launchUri"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-external-link" aria-hidden="true"></i>
|
||||
{{ "launch" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
|
||||
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
@if (!decryptionFailure && !hideMenu) {
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAssignToCollections"
|
||||
[bitMenuTriggerFor]="cipherOptions"
|
||||
[disabled]="disabled"
|
||||
size="small"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
type="button"
|
||||
(click)="assignToCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
appStopProp
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #cipherOptions>
|
||||
<ng-container *ngIf="isNotDeletedLoginCipher">
|
||||
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyUsername" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="copy('password')" *ngIf="hasPasswordToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyPassword" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="copy('totp')" *ngIf="showTotpCopyButton">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyVerificationCode" | i18n }}
|
||||
</button>
|
||||
<a
|
||||
bitMenuItem
|
||||
*ngIf="canLaunch"
|
||||
type="button"
|
||||
[href]="launchUri"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-external-link" aria-hidden="true"></i>
|
||||
{{ "launch" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
|
||||
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAssignToCollections"
|
||||
type="button"
|
||||
(click)="assignToCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="canDeleteCipher" (click)="deleteCipher()" type="button">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ (isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -189,8 +189,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return this.i18nService.t("noAccess");
|
||||
}
|
||||
|
||||
protected get showCopyUsername(): boolean {
|
||||
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
|
||||
return this.isNotDeletedLoginCipher && usernameCopy;
|
||||
}
|
||||
|
||||
protected get showCopyPassword(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.cipher.viewPassword;
|
||||
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
|
||||
return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy;
|
||||
}
|
||||
|
||||
protected get showCopyTotp(): boolean {
|
||||
@@ -201,16 +207,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return this.isNotDeletedLoginCipher && this.canLaunch;
|
||||
}
|
||||
|
||||
protected get disableMenu() {
|
||||
protected get isDeletedCanRestore(): boolean {
|
||||
return CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher;
|
||||
}
|
||||
|
||||
protected get hideMenu() {
|
||||
return !(
|
||||
this.isNotDeletedLoginCipher ||
|
||||
this.isDeletedCanRestore ||
|
||||
this.showCopyUsername ||
|
||||
this.showCopyPassword ||
|
||||
this.showCopyTotp ||
|
||||
this.showLaunchUri ||
|
||||
this.showAttachments ||
|
||||
this.showClone ||
|
||||
this.canEditCipher ||
|
||||
(CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher)
|
||||
this.canEditCipher
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ import { VaultItem } from "./vault-item";
|
||||
import { VaultItemEvent } from "./vault-item-event";
|
||||
|
||||
// Fixed manual row height required due to how cdk-virtual-scroll works
|
||||
export const RowHeight = 75.5;
|
||||
export const RowHeightClass = `tw-h-[75.5px]`;
|
||||
export const RowHeight = 75;
|
||||
export const RowHeightClass = `tw-h-[75px]`;
|
||||
|
||||
const MaxSelectionCount = 500;
|
||||
|
||||
@@ -166,6 +166,10 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
);
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
get showExtraColumn() {
|
||||
return this.showCollections || this.showGroups || this.showOwner;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
@@ -262,7 +263,7 @@ export const OrganizationTrash: Story = {
|
||||
};
|
||||
|
||||
const unassignedCollection = new CollectionAdminView();
|
||||
unassignedCollection.id = Unassigned;
|
||||
unassignedCollection.id = Unassigned as CollectionId;
|
||||
unassignedCollection.name = "Unassigned";
|
||||
export const OrganizationTopLevelCollection: Story = {
|
||||
args: {
|
||||
@@ -327,7 +328,7 @@ function createCollectionView(i: number): CollectionAdminView {
|
||||
const organization = organizations[i % (organizations.length + 1)];
|
||||
const group = groups[i % (groups.length + 1)];
|
||||
const view = new CollectionAdminView();
|
||||
view.id = `collection-${i}`;
|
||||
view.id = `collection-${i}` as CollectionId;
|
||||
view.name = `Collection ${i}`;
|
||||
view.organizationId = organization?.id;
|
||||
view.manage = true;
|
||||
@@ -357,7 +358,7 @@ function createGroupView(i: number): GroupView {
|
||||
|
||||
function createOrganization(i: number): Organization {
|
||||
const organization = new Organization();
|
||||
organization.id = `organization-${i}`;
|
||||
organization.id = `organization-${i}` as OrganizationId;
|
||||
organization.name = `Organization ${i}`;
|
||||
organization.type = OrganizationUserType.Owner;
|
||||
organization.permissions = new PermissionsApi();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Injectable, OnDestroy } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationExtras } from "@angular/router";
|
||||
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
isRoutedVaultFilterItemType,
|
||||
RoutedVaultFilterModel,
|
||||
@@ -31,10 +33,12 @@ export class RoutedVaultFilterService implements OnDestroy {
|
||||
const type = isRoutedVaultFilterItemType(unsafeType) ? unsafeType : undefined;
|
||||
|
||||
return {
|
||||
collectionId: queryParams.get("collectionId") ?? undefined,
|
||||
collectionId: (queryParams.get("collectionId") as CollectionId) ?? undefined,
|
||||
folderId: queryParams.get("folderId") ?? undefined,
|
||||
organizationId:
|
||||
params.get("organizationId") ?? queryParams.get("organizationId") ?? undefined,
|
||||
(params.get("organizationId") as OrganizationId) ??
|
||||
(queryParams.get("organizationId") as OrganizationId) ??
|
||||
undefined,
|
||||
organizationIdParamType:
|
||||
params.get("organizationId") != undefined ? ("path" as const) : ("query" as const),
|
||||
type,
|
||||
|
||||
@@ -28,7 +28,7 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -209,7 +209,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
||||
|
||||
protected getOrganizationFilterMyVault(): TreeNode<OrganizationFilter> {
|
||||
const myVault = new Organization() as OrganizationFilter;
|
||||
myVault.id = "MyVault";
|
||||
myVault.id = "MyVault" as OrganizationId;
|
||||
myVault.icon = "bwi-user";
|
||||
myVault.enabled = true;
|
||||
myVault.hideOptions = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Unassigned } from "@bitwarden/admin-console/common";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
@@ -65,7 +66,7 @@ export class RoutedVaultFilterBridge implements VaultFilter {
|
||||
let type: RoutedVaultFilterItemType | undefined;
|
||||
|
||||
if (value?.node.id === "AllItems" && this.routedFilter.organizationIdParamType === "path") {
|
||||
type = "all";
|
||||
type = All;
|
||||
} else if (
|
||||
value?.node.id === "AllItems" &&
|
||||
this.routedFilter.organizationIdParamType === "query"
|
||||
@@ -98,7 +99,7 @@ export class RoutedVaultFilterBridge implements VaultFilter {
|
||||
return this.legacyFilter.selectedCollectionNode;
|
||||
}
|
||||
set selectedCollectionNode(value: TreeNode<CollectionFilter>) {
|
||||
let collectionId: string | undefined;
|
||||
let collectionId: CollectionId | All | Unassigned | undefined;
|
||||
|
||||
if (value != null && value.node.id === null) {
|
||||
collectionId = Unassigned;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Unassigned } from "@bitwarden/admin-console/common";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* A constant used to represent viewing "all" of a particular filter.
|
||||
*/
|
||||
export const All = "all";
|
||||
export type All = typeof All;
|
||||
|
||||
// TODO: Remove `All` when moving to vertical navigation.
|
||||
const itemTypes = [
|
||||
@@ -19,9 +26,9 @@ export function isRoutedVaultFilterItemType(value: unknown): value is RoutedVaul
|
||||
}
|
||||
|
||||
export interface RoutedVaultFilterModel {
|
||||
collectionId?: string;
|
||||
collectionId?: CollectionId | All | Unassigned;
|
||||
folderId?: string;
|
||||
organizationId?: string;
|
||||
organizationId?: OrganizationId | Unassigned;
|
||||
type?: RoutedVaultFilterItemType;
|
||||
|
||||
organizationIdParamType?: "path" | "query";
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
{{ trashCleanupWarning }}
|
||||
</bit-callout>
|
||||
<app-vault-items
|
||||
#vaultItems
|
||||
[ciphers]="ciphers"
|
||||
[collections]="collections"
|
||||
[allCollections]="allCollections"
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
} from "../components/vault-item-dialog/vault-item-dialog.component";
|
||||
import { VaultItem } from "../components/vault-items/vault-item";
|
||||
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
|
||||
import { VaultItemsComponent } from "../components/vault-items/vault-items.component";
|
||||
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
|
||||
|
||||
import {
|
||||
@@ -156,6 +157,7 @@ const SearchTextDebounceInterval = 200;
|
||||
})
|
||||
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
|
||||
@ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent;
|
||||
@ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent<C>;
|
||||
|
||||
trashCleanupWarning: string = null;
|
||||
kdfIterations: number;
|
||||
@@ -1281,6 +1283,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
|
||||
private refresh() {
|
||||
this.refresh$.next();
|
||||
this.vaultItemsComponent?.clearSelection();
|
||||
}
|
||||
|
||||
private async go(queryParams: any = null) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum
|
||||
* used to allow for the extension to open and then emit to the message.
|
||||
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
|
||||
*/
|
||||
const OPEN_RESPONSE_TIMEOUT_MS = 1500;
|
||||
const OPEN_RESPONSE_TIMEOUT_MS = 2000;
|
||||
|
||||
/**
|
||||
* Timeout for checking if the extension is installed.
|
||||
|
||||
@@ -1428,9 +1428,6 @@
|
||||
"notificationSentDevicePart1": {
|
||||
"message": "Unlock Bitwarden on your device or on the "
|
||||
},
|
||||
"areYouTryingToAccessYourAccount": {
|
||||
"message": "Are you trying to access your account?"
|
||||
},
|
||||
"accessAttemptBy": {
|
||||
"message": "Access attempt by $EMAIL$",
|
||||
"placeholders": {
|
||||
@@ -3981,22 +3978,6 @@
|
||||
"thisRequestIsNoLongerValid": {
|
||||
"message": "This request is no longer valid."
|
||||
},
|
||||
"logInConfirmedForEmailOnDevice": {
|
||||
"message": "Login confirmed for $EMAIL$ on $DEVICE$",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "name@example.com"
|
||||
},
|
||||
"device": {
|
||||
"content": "$2",
|
||||
"example": "iOS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"youDeniedALogInAttemptFromAnotherDevice": {
|
||||
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
|
||||
},
|
||||
"loginRequestApprovedForEmailOnDevice": {
|
||||
"message": "Login request approved for $EMAIL$ on $DEVICE$",
|
||||
"placeholders": {
|
||||
@@ -5448,6 +5429,37 @@
|
||||
"organizationDataOwnership": {
|
||||
"message": "Enforce organization data ownership"
|
||||
},
|
||||
"organizationDataOwnershipDesc": {
|
||||
"message": "Require all items to be owned by an organization, removing the option to store items at the account level.",
|
||||
"description": "This is the policy description shown in the policy list."
|
||||
},
|
||||
"organizationDataOwnershipContent": {
|
||||
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
|
||||
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
|
||||
},
|
||||
"organizationDataOwnershipContentAnchor":{
|
||||
"message": "credential lifecycle",
|
||||
"description": "This will be used as a hyperlink"
|
||||
},
|
||||
"organizationDataOwnershipWarningTitle":{
|
||||
"message": "Are you sure you want to proceed?"
|
||||
},
|
||||
"organizationDataOwnershipWarning1":{
|
||||
"message": "will remain accessible to members"
|
||||
},
|
||||
"organizationDataOwnershipWarning2":{
|
||||
"message": "will not be automatically selected when creating new items"
|
||||
},
|
||||
"organizationDataOwnershipWarning3":{
|
||||
"message": "cannot be managed from the Admin Console until the user is offboarded"
|
||||
},
|
||||
"organizationDataOwnershipWarningContentTop":{
|
||||
"message": "By turning this policy off, the default collection: "
|
||||
},
|
||||
"organizationDataOwnershipWarningContentBottom":{
|
||||
"message": "Learn more about the ",
|
||||
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
|
||||
},
|
||||
"personalOwnership": {
|
||||
"message": "Remove individual vault"
|
||||
},
|
||||
@@ -10984,5 +10996,11 @@
|
||||
},
|
||||
"unlimitedSecretsAndProjects": {
|
||||
"message": "Unlimited secrets and projects"
|
||||
},
|
||||
"providersubscriptionCanceled": {
|
||||
"message": "Subscription canceled"
|
||||
},
|
||||
"providersubCanceledmessage": {
|
||||
"message" : "To resubscribe, contact Bitwarden Customer Support."
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
BasePolicy,
|
||||
BasePolicyComponent,
|
||||
@@ -13,8 +15,8 @@ export class ActivateAutofillPolicy extends BasePolicy {
|
||||
type = PolicyType.ActivateAutofill;
|
||||
component = ActivateAutofillPolicyComponent;
|
||||
|
||||
display(organization: Organization) {
|
||||
return organization.useActivateAutofillPolicy;
|
||||
display(organization: Organization, configService: ConfigService) {
|
||||
return of(organization.useActivateAutofillPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
dataSource = new MembersTableDataSource();
|
||||
loading = true;
|
||||
providerId: string;
|
||||
rowHeight = 69;
|
||||
rowHeightClass = `tw-h-[69px]`;
|
||||
rowHeight = 70;
|
||||
rowHeightClass = `tw-h-[70px]`;
|
||||
status: ProviderUserStatusType = null;
|
||||
|
||||
userStatusType = ProviderUserStatusType;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user