mirror of
https://github.com/bitwarden/browser
synced 2026-02-20 11:24:07 +00:00
Merge branch 'main' into beeep/dev-container
This commit is contained in:
2
.github/workflows/build-browser.yml
vendored
2
.github/workflows/build-browser.yml
vendored
@@ -565,7 +565,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Upload Sources
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
|
||||
8
.github/workflows/build-desktop.yml
vendored
8
.github/workflows/build-desktop.yml
vendored
@@ -1007,7 +1007,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.14.2'
|
||||
|
||||
@@ -1247,7 +1247,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.14.2'
|
||||
|
||||
@@ -1522,7 +1522,7 @@ jobs:
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.14.2'
|
||||
|
||||
@@ -1873,7 +1873,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Upload Sources
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -352,7 +352,7 @@ jobs:
|
||||
- name: Scan Docker image
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
|
||||
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
|
||||
with:
|
||||
image: ${{ steps.image-name.outputs.name }}
|
||||
fail-build: false
|
||||
@@ -408,7 +408,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Upload Sources
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
|
||||
2
.github/workflows/lint-crowdin-config.yml
vendored
2
.github/workflows/lint-crowdin-config.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Lint ${{ matrix.app.name }} config
|
||||
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }}
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
run: cargo +nightly udeps --workspace --all-features --all-targets
|
||||
|
||||
- name: Install cargo-deny
|
||||
uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7
|
||||
uses: taiki-e/install-action@887bc4e03483810873d617344dd5189cd82e7b8b # v2.67.11
|
||||
with:
|
||||
tool: cargo-deny@0.18.6
|
||||
|
||||
|
||||
2
.github/workflows/publish-desktop.yml
vendored
2
.github/workflows/publish-desktop.yml
vendored
@@ -331,7 +331,7 @@ jobs:
|
||||
run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json"
|
||||
|
||||
- name: Setup Ruby and Install Fastlane
|
||||
uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0
|
||||
uses: ruby/setup-ruby@90be1154f987f4dc0fe0dd0feedac9e473aa4ba8 # v1.286.0
|
||||
with:
|
||||
ruby-version: '3.4.7'
|
||||
bundler-cache: false
|
||||
|
||||
@@ -716,7 +716,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
stroke-dasharray="78.5"
|
||||
stroke-dashoffset="78.5"
|
||||
stroke-width="3"
|
||||
style="stroke-dashoffset: NaN;"
|
||||
style="stroke-dashoffset: 34.033920413889426;"
|
||||
transform="rotate(-90 14.5 14.5)"
|
||||
/>
|
||||
|
||||
@@ -737,7 +737,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
bittypography="helper"
|
||||
class="totp-sec-span"
|
||||
>
|
||||
NaN
|
||||
17
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -2115,7 +2115,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
stroke-dasharray="78.5"
|
||||
stroke-dashoffset="78.5"
|
||||
stroke-width="3"
|
||||
style="stroke-dashoffset: NaN;"
|
||||
style="stroke-dashoffset: 34.033920413889426;"
|
||||
transform="rotate(-90 14.5 14.5)"
|
||||
/>
|
||||
|
||||
@@ -2136,7 +2136,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
bittypography="helper"
|
||||
class="totp-sec-span"
|
||||
>
|
||||
NaN
|
||||
17
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -2227,7 +2227,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
stroke-dasharray="78.5"
|
||||
stroke-dashoffset="78.5"
|
||||
stroke-width="3"
|
||||
style="stroke-dashoffset: NaN;"
|
||||
style="stroke-dashoffset: 34.033920413889426;"
|
||||
transform="rotate(-90 14.5 14.5)"
|
||||
/>
|
||||
|
||||
@@ -2248,7 +2248,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
|
||||
bittypography="helper"
|
||||
class="totp-sec-span"
|
||||
>
|
||||
NaN
|
||||
17
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -157,6 +157,8 @@ describe("AutofillInlineMenuList", () => {
|
||||
});
|
||||
|
||||
it("creates the view for a totp field", async () => {
|
||||
jest.spyOn(Date, "now").mockReturnValue(13000);
|
||||
|
||||
postWindowMessage(
|
||||
createInitAutofillInlineMenuListMessageMock({
|
||||
inlineMenuFillType: CipherType.Login,
|
||||
@@ -184,6 +186,8 @@ describe("AutofillInlineMenuList", () => {
|
||||
});
|
||||
|
||||
it("renders correctly when there are multiple TOTP elements with username displayed", async () => {
|
||||
jest.spyOn(Date, "now").mockReturnValue(13000);
|
||||
|
||||
const totpCipher1 = createAutofillOverlayCipherDataMock(1, {
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import "@webcomponents/custom-elements";
|
||||
import "lit/polyfill-support.js";
|
||||
|
||||
@@ -33,27 +31,36 @@ import {
|
||||
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
|
||||
|
||||
export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
private inlineMenuListContainer: HTMLDivElement;
|
||||
private passwordGeneratorContainer: HTMLDivElement;
|
||||
/** Non-null asserted. Set in initAutofillInlineMenuList before any read. */
|
||||
private inlineMenuListContainer!: HTMLDivElement;
|
||||
/** Non-null asserted. Set in initAutofillInlineMenuList before any read. */
|
||||
private passwordGeneratorContainer!: HTMLDivElement;
|
||||
private resizeObserver: ResizeObserver;
|
||||
private eventHandlersMemo: { [key: string]: EventListener } = {};
|
||||
private ciphers: InlineMenuCipherData[] = [];
|
||||
private ciphersList: HTMLUListElement;
|
||||
/** Non-null asserted. Set in buildInlineMenuList before any read. */
|
||||
private ciphersList!: HTMLUListElement;
|
||||
private cipherListScrollIsDebounced = false;
|
||||
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
|
||||
private cipherListScrollDebounceTimeout: number | ReturnType<typeof setTimeout> = 0;
|
||||
private currentCipherIndex = 0;
|
||||
private inlineMenuFillType: InlineMenuFillType;
|
||||
private showInlineMenuAccountCreation: boolean;
|
||||
private showPasskeysLabels: boolean;
|
||||
private newItemButtonElement: HTMLButtonElement;
|
||||
private passkeysHeadingElement: HTMLLIElement;
|
||||
private loginHeadingElement: HTMLLIElement;
|
||||
private lastPasskeysListItem: HTMLLIElement;
|
||||
private passkeysHeadingHeight: number;
|
||||
private lastPasskeysListItemHeight: number;
|
||||
private ciphersListHeight: number;
|
||||
/** Non-null asserted. Set in initAutofillInlineMenuList from message. */
|
||||
private inlineMenuFillType!: InlineMenuFillType;
|
||||
private showInlineMenuAccountCreation = false;
|
||||
private showPasskeysLabels = false;
|
||||
/** Non-null asserted. Set in buildNewItemButton before any read. */
|
||||
private newItemButtonElement!: HTMLButtonElement;
|
||||
/** Conditionally set in buildPasskeysHeadingElements, may be undefined when no passkeys. */
|
||||
private passkeysHeadingElement?: HTMLLIElement;
|
||||
/** Conditionally set in buildPasskeysHeadingElements, may be undefined when no login heading. */
|
||||
private loginHeadingElement?: HTMLLIElement;
|
||||
/** Conditionally set in buildInlineMenuListActionsItem when showPasskeysLabels and passkey cipher. */
|
||||
private lastPasskeysListItem?: HTMLLIElement;
|
||||
private passkeysHeadingHeight = 0;
|
||||
private lastPasskeysListItemHeight = 0;
|
||||
private ciphersListHeight = 0;
|
||||
private isPasskeyAuthInProgress = false;
|
||||
private authStatus: AuthenticationStatus;
|
||||
private authStatus: AuthenticationStatus = AuthenticationStatus.Locked;
|
||||
private isInitialized = false;
|
||||
private readonly showCiphersPerPage = 6;
|
||||
private readonly headingBorderClass = "inline-menu-list-heading--bordered";
|
||||
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
|
||||
@@ -70,6 +77,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
|
||||
this.setupInlineMenuListGlobalListeners();
|
||||
}
|
||||
|
||||
@@ -85,11 +93,11 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
styleSheetUrl,
|
||||
theme,
|
||||
authStatus,
|
||||
ciphers,
|
||||
ciphers = [],
|
||||
portKey,
|
||||
inlineMenuFillType,
|
||||
showInlineMenuAccountCreation,
|
||||
showPasskeysLabels,
|
||||
inlineMenuFillType = CipherType.Login,
|
||||
showInlineMenuAccountCreation = false,
|
||||
showPasskeysLabels = false,
|
||||
generatedPassword,
|
||||
showSaveLoginMenu,
|
||||
} = message;
|
||||
@@ -112,6 +120,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
this.resizeObserver.observe(this.inlineMenuListContainer);
|
||||
|
||||
this.shadowDom.append(linkElement, this.inlineMenuListContainer);
|
||||
this.isInitialized = true;
|
||||
|
||||
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||
this.buildLockedInlineMenu();
|
||||
@@ -368,7 +377,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
event.target.nextElementSibling
|
||||
) {
|
||||
(event.target.nextElementSibling as HTMLElement).focus();
|
||||
event.target.parentElement.classList.add("remove-outline");
|
||||
event.target.parentElement?.classList.add("remove-outline");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -409,7 +418,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
event.target.previousElementSibling
|
||||
) {
|
||||
(event.target.previousElementSibling as HTMLElement).focus();
|
||||
event.target.parentElement.classList.remove("remove-outline");
|
||||
event.target.parentElement?.classList.remove("remove-outline");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -473,8 +482,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* @param showInlineMenuAccountCreation - Whether identity ciphers are shown on login fields.
|
||||
*/
|
||||
private updateListItems({
|
||||
ciphers,
|
||||
showInlineMenuAccountCreation,
|
||||
ciphers = [],
|
||||
showInlineMenuAccountCreation = false,
|
||||
}: UpdateAutofillInlineMenuListCiphersParams) {
|
||||
if (this.isPasskeyAuthInProgress) {
|
||||
return;
|
||||
@@ -655,7 +664,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* scroll listeners that reposition the passkeys and login headings when the user scrolls.
|
||||
*/
|
||||
private setupCipherListScrollListeners() {
|
||||
const options = { passive: true };
|
||||
const options: AddEventListenerOptions = { passive: true };
|
||||
this.ciphersList.addEventListener(EVENTS.SCROLL, this.updateCiphersListOnScroll, options);
|
||||
if (this.showPasskeysLabels) {
|
||||
this.ciphersList.addEventListener(
|
||||
@@ -673,8 +682,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* Handles updating the list of ciphers when the
|
||||
* user scrolls to the bottom of the list.
|
||||
*/
|
||||
private updateCiphersListOnScroll = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
private updateCiphersListOnScroll = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.cipherListScrollIsDebounced) {
|
||||
@@ -721,8 +729,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
*
|
||||
* @param event - The scroll event.
|
||||
*/
|
||||
private handleThrottledOnScrollEvent = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
private handleThrottledOnScrollEvent = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop);
|
||||
@@ -754,6 +761,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
|
||||
*/
|
||||
private togglePasskeysHeadingAnchored(cipherListScrollTop: number) {
|
||||
if (!this.passkeysHeadingElement || !this.lastPasskeysListItem) {
|
||||
return;
|
||||
}
|
||||
if (!this.passkeysHeadingHeight) {
|
||||
this.passkeysHeadingHeight = this.passkeysHeadingElement.offsetHeight;
|
||||
}
|
||||
@@ -776,6 +786,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
|
||||
*/
|
||||
private togglePasskeysHeadingBorder(cipherListScrollTop: number) {
|
||||
if (!this.passkeysHeadingElement) {
|
||||
return;
|
||||
}
|
||||
if (cipherListScrollTop < 1) {
|
||||
this.passkeysHeadingElement.classList.remove(this.headingBorderClass);
|
||||
return;
|
||||
@@ -791,6 +804,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* @param cipherListScrollTop - The current scroll top position of the ciphers list.
|
||||
*/
|
||||
private toggleLoginHeadingBorder(cipherListScrollTop: number) {
|
||||
if (!this.loginHeadingElement || !this.lastPasskeysListItem) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastPasskeysListItemHeight) {
|
||||
this.lastPasskeysListItemHeight = this.lastPasskeysListItem.offsetHeight;
|
||||
}
|
||||
@@ -884,7 +900,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
);
|
||||
|
||||
this.addFillCipherElementAriaDescription(fillCipherElement, cipher);
|
||||
fillCipherElement.append(cipherIcon, cipherDetailsElement);
|
||||
fillCipherElement.append(cipherIcon, ...(cipherDetailsElement ? [cipherDetailsElement] : []));
|
||||
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
|
||||
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
|
||||
|
||||
@@ -1126,7 +1142,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
|
||||
cipherIcon.appendChild(totpContainer);
|
||||
|
||||
const intervalSeconds = cipher.login.totpCodeTimeInterval;
|
||||
const intervalSeconds = cipher.login.totpCodeTimeInterval ?? 30;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const epoch = Math.round(Date.now() / 1000);
|
||||
@@ -1266,7 +1282,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
|
||||
if (this.multipleTotpElements() && username) {
|
||||
const usernameSubtitle = this.buildCipherSubtitleElement(username);
|
||||
containerElement.appendChild(usernameSubtitle);
|
||||
if (usernameSubtitle) {
|
||||
containerElement.appendChild(usernameSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
const totpCodeSpan = document.createElement("span");
|
||||
@@ -1326,19 +1344,25 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
cipher: InlineMenuCipherData,
|
||||
cipherDetailsElement: HTMLSpanElement,
|
||||
): HTMLSpanElement {
|
||||
let rpNameSubtitle: HTMLSpanElement;
|
||||
const login = cipher.login;
|
||||
const passkey = login?.passkey;
|
||||
if (!login || !passkey) {
|
||||
return cipherDetailsElement;
|
||||
}
|
||||
|
||||
if (cipher.name !== cipher.login.passkey.rpName) {
|
||||
rpNameSubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.rpName);
|
||||
if (rpNameSubtitle) {
|
||||
let rpNameSubtitle: HTMLSpanElement | undefined;
|
||||
if (cipher.name !== passkey.rpName) {
|
||||
const element = this.buildCipherSubtitleElement(passkey.rpName);
|
||||
if (element) {
|
||||
rpNameSubtitle = element;
|
||||
rpNameSubtitle.prepend(buildSvgDomElement(passkeyIcon));
|
||||
rpNameSubtitle.classList.add("cipher-subtitle--passkey");
|
||||
cipherDetailsElement.appendChild(rpNameSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
if (cipher.login.username) {
|
||||
const usernameSubtitle = this.buildCipherSubtitleElement(cipher.login.username);
|
||||
if (login.username) {
|
||||
const usernameSubtitle = this.buildCipherSubtitleElement(login.username);
|
||||
if (usernameSubtitle) {
|
||||
if (!rpNameSubtitle) {
|
||||
usernameSubtitle.prepend(buildSvgDomElement(passkeyIcon));
|
||||
@@ -1350,7 +1374,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
return cipherDetailsElement;
|
||||
}
|
||||
|
||||
const passkeySubtitle = this.buildCipherSubtitleElement(cipher.login.passkey.userName);
|
||||
const passkeySubtitle = this.buildCipherSubtitleElement(passkey.userName);
|
||||
if (passkeySubtitle) {
|
||||
if (!rpNameSubtitle) {
|
||||
passkeySubtitle.prepend(buildSvgDomElement(passkeyIcon));
|
||||
@@ -1412,6 +1436,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* If not focused, will check if the button element is focused.
|
||||
*/
|
||||
private checkInlineMenuListFocused() {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
if (globalThis.document.hasFocus()) {
|
||||
return;
|
||||
}
|
||||
@@ -1450,6 +1477,9 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
* the first cipher button.
|
||||
*/
|
||||
private focusInlineMenuList() {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
this.inlineMenuListContainer.setAttribute("role", "dialog");
|
||||
this.inlineMenuListContainer.setAttribute("aria-modal", "true");
|
||||
|
||||
@@ -1472,8 +1502,6 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
|
||||
*/
|
||||
private setupInlineMenuListGlobalListeners() {
|
||||
this.setupGlobalListeners(this.inlineMenuListWindowMessageHandlers);
|
||||
|
||||
this.resizeObserver = new ResizeObserver(this.handleResizeObserver);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -368,20 +368,21 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri
|
||||
/**
|
||||
* Throttles a callback function to run at most once every `limit` milliseconds.
|
||||
*
|
||||
* @param callback - The callback function to throttle.
|
||||
* @param callback - The callback function to throttle (must return void).
|
||||
* @param limit - The time in milliseconds to throttle the callback.
|
||||
*/
|
||||
export function throttle<FunctionType extends (...args: unknown[]) => unknown>(
|
||||
callback: FunctionType,
|
||||
export function throttle<TypeContext, Args extends unknown[]>(
|
||||
callback: (this: TypeContext, ...args: Args) => void,
|
||||
limit: number,
|
||||
): (this: ThisParameterType<FunctionType>, ...args: Parameters<FunctionType>) => void {
|
||||
): (this: TypeContext, ...args: Args) => void {
|
||||
let waitingDelay = false;
|
||||
return function (this: ThisParameterType<FunctionType>, ...args: Parameters<FunctionType>) {
|
||||
if (!waitingDelay) {
|
||||
callback.apply(this, args);
|
||||
waitingDelay = true;
|
||||
globalThis.setTimeout(() => (waitingDelay = false), limit);
|
||||
return function (this: TypeContext, ...args: Args) {
|
||||
if (waitingDelay) {
|
||||
return;
|
||||
}
|
||||
callback.apply(this, args);
|
||||
waitingDelay = true;
|
||||
globalThis.setTimeout(() => (waitingDelay = false), limit);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1008,7 +1008,6 @@ export default class MainBackground {
|
||||
this.keyGenerationService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
this.configService,
|
||||
);
|
||||
this.sendApiService = new SendApiService(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
concatMap,
|
||||
@@ -73,9 +74,25 @@ export class BadgeService {
|
||||
map((evt) => evt.tab),
|
||||
combineLatestWith(this.stateFunctions),
|
||||
switchMap(([tab, dynamicStateFunctions]) => {
|
||||
const functions = [...Object.values(dynamicStateFunctions), defaultTabStateFunction];
|
||||
const functions = [
|
||||
...Object.entries(dynamicStateFunctions),
|
||||
["default" as string, defaultTabStateFunction] as const,
|
||||
];
|
||||
|
||||
return combineLatest(functions.map((f) => f(tab).pipe(startWith(undefined)))).pipe(
|
||||
return combineLatest(
|
||||
functions.map(([name, f]) =>
|
||||
f(tab).pipe(
|
||||
startWith(undefined),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(
|
||||
`BadgeService: State function "${name}" threw an error`,
|
||||
error,
|
||||
);
|
||||
return of(undefined);
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
map((states) => ({
|
||||
tab,
|
||||
states: states.filter((s): s is BadgeStateSetting => s !== undefined),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[backAction]="handleBackButton"
|
||||
showBackButton
|
||||
>
|
||||
@if (config?.originalCipher?.archivedDate) {
|
||||
@if (config?.originalCipher?.archivedDate && (archiveFlagEnabled$ | async)) {
|
||||
<ng-container slot="end">
|
||||
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
|
||||
{{ "archived" | i18n }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<popup-page>
|
||||
<popup-header slot="header" [pageTitle]="headerText" showBackButton>
|
||||
<ng-container slot="end">
|
||||
@if (cipher?.isArchived) {
|
||||
@if (cipher?.isArchived && (archiveFlagEnabled$ | async)) {
|
||||
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
|
||||
{{ "archived" | i18n }}
|
||||
</span>
|
||||
|
||||
@@ -615,7 +615,6 @@ export class ServiceContainer {
|
||||
this.keyGenerationService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
this.cryptoFunctionService,
|
||||
this.configService,
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re
|
||||
IgnorableNamespaces="uap rescap com uap10 build"
|
||||
xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build">
|
||||
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
|
||||
<Identity Name="${applicationId}"
|
||||
<Identity Name="${identityName}"
|
||||
ProcessorArchitecture="${arch}"
|
||||
Publisher='${publisher}'
|
||||
Version="${version}" />
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"customManifestPath": "./custom-appx-manifest.xml",
|
||||
"applicationId": "BitwardenBeta",
|
||||
"identityName": "8bitSolutionsLLC.BitwardenBeta",
|
||||
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
|
||||
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
|
||||
"publisherDisplayName": "Bitwarden Inc",
|
||||
"languages": [
|
||||
"en-US",
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"customManifestPath": "./custom-appx-manifest.xml",
|
||||
"applicationId": "bitwardendesktop",
|
||||
"identityName": "8bitSolutionsLLC.bitwardendesktop",
|
||||
"publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US",
|
||||
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
|
||||
"publisherDisplayName": "Bitwarden Inc",
|
||||
"languages": [
|
||||
"en-US",
|
||||
|
||||
@@ -176,6 +176,7 @@ $translationMap = @{
|
||||
'applicationId' = $builderConfig.appx.applicationId
|
||||
'displayName' = $productName
|
||||
'executable' = "app\${productName}.exe"
|
||||
'identityName' = $builderConfig.appx.identityName
|
||||
'publisher' = $builderConfig.appx.publisher
|
||||
'publisherDisplayName' = $builderConfig.appx.publisherDisplayName
|
||||
'version' = $version
|
||||
|
||||
@@ -3,7 +3,7 @@ const child_process = require("child_process");
|
||||
|
||||
exports.default = async function (configuration) {
|
||||
const ext = configuration.path.split(".").at(-1);
|
||||
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) {
|
||||
if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe"].includes(ext)) {
|
||||
console.log(`[*] Signing file: ${configuration.path}`);
|
||||
child_process.execFileSync(
|
||||
"azuresigntool",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
@if (isCipherArchived && !params.isAdminConsoleAction) {
|
||||
@if (isCipherArchived && !params.isAdminConsoleAction && (archiveFlagEnabled$ | async)) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
|
||||
@if (showActionButtons) {
|
||||
<div class="tw-ml-auto">
|
||||
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) {
|
||||
@if (isCipherArchived && !cipher?.isDeleted) {
|
||||
@if (showArchiveOptions) {
|
||||
@if (showUnarchiveBtn) {
|
||||
<button
|
||||
type="button"
|
||||
class="tw-mr-1"
|
||||
@@ -96,7 +96,7 @@
|
||||
[label]="'unArchive' | i18n"
|
||||
></button>
|
||||
}
|
||||
@if (cipher?.canBeArchived) {
|
||||
@if (showArchiveBtn) {
|
||||
<button
|
||||
type="button"
|
||||
class="tw-mr-1"
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("VaultItemDialogComponent", () => {
|
||||
provide: CipherArchiveService,
|
||||
useValue: {
|
||||
userCanArchive$: jest.fn().mockReturnValue(of(true)),
|
||||
hasArchiveFlagEnabled$: jest.fn().mockReturnValue(of(true)),
|
||||
hasArchiveFlagEnabled$: of(true),
|
||||
archiveWithServer: jest.fn().mockResolvedValue({}),
|
||||
unarchiveWithServer: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
@@ -258,19 +258,19 @@ describe("VaultItemDialogComponent", () => {
|
||||
expect(archiveButton).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should show archive button when the user can archive the item and the item can be archived", () => {
|
||||
it("should show archive button when the user can archive the item, item can be archived, and dialog is in view mode", () => {
|
||||
component.setTestCipher({ canBeArchived: true });
|
||||
(component as any).userCanArchive$ = of(true);
|
||||
component.setTestParams({ mode: "form" });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
|
||||
expect(archiveButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not show archive button when the user cannot archive the item", () => {
|
||||
it("should not show archive button when the user does not have premium", () => {
|
||||
(component as any).userCanArchive$ = of(false);
|
||||
component.setTestCipher({});
|
||||
component.setTestParams({ mode: "form" });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
|
||||
expect(archiveButton).toBeFalsy();
|
||||
@@ -283,18 +283,35 @@ describe("VaultItemDialogComponent", () => {
|
||||
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
|
||||
expect(archiveButton).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not show archive button when dialog is not in view mode", () => {
|
||||
component.setTestCipher({ canBeArchived: true });
|
||||
(component as any).userCanArchive$ = of(true);
|
||||
component.setTestParams({ mode: "form" });
|
||||
fixture.detectChanges();
|
||||
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
|
||||
expect(archiveButton).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unarchive button", () => {
|
||||
it("should show the unarchive button when the item is archived", () => {
|
||||
it("should show the unarchive button when the item is archived, and dialog in view mode", () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "form" });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
|
||||
expect(unarchiveButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not show the unarchive button when the item is not archived", () => {
|
||||
component.setTestCipher({ isArchived: false });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']"));
|
||||
expect(unarchiveButton).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not show the unarchive button when dialog is not in view mode", () => {
|
||||
component.setTestCipher({ isArchived: false });
|
||||
component.setTestParams({ mode: "form" });
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -28,7 +28,7 @@ import { EventType } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
@@ -293,6 +293,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
return this.cipher?.isArchived;
|
||||
}
|
||||
|
||||
private _userCanArchive = false;
|
||||
|
||||
protected get showArchiveOptions(): boolean {
|
||||
return this._userCanArchive && !this.params.isAdminConsoleAction && this.params.mode === "view";
|
||||
}
|
||||
|
||||
protected get showArchiveBtn(): boolean {
|
||||
return this.cipher?.canBeArchived;
|
||||
}
|
||||
|
||||
protected get showUnarchiveBtn(): boolean {
|
||||
return this.isCipherArchived && !this.cipher?.isDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag to initialize/attach the form component.
|
||||
*/
|
||||
@@ -341,6 +355,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.userCanArchive$.pipe(takeUntilDestroyed()).subscribe((v) => (this._userCanArchive = v));
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -508,11 +524,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
const dialogRef = this.dialogService.open<
|
||||
AttachmentDialogCloseResult,
|
||||
{ cipherId: CipherId; organizationId?: OrganizationId }
|
||||
{ cipherId: CipherId; organizationId?: OrganizationId; canEditCipher?: boolean }
|
||||
>(AttachmentsV2Component, {
|
||||
data: {
|
||||
cipherId: this.formConfig.originalCipher?.id as CipherId,
|
||||
organizationId: this.formConfig.originalCipher?.organizationId as OrganizationId,
|
||||
canEditCipher: this.formConfig.originalCipher?.edit,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -574,20 +591,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
await this.changeMode("view");
|
||||
};
|
||||
|
||||
updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => {
|
||||
this.cipher.archivedDate = archivedDate;
|
||||
this.cipher.revisionDate = revisionDate;
|
||||
updateCipherFromResponse = async (cipherResponse: CipherData, userId: UserId) => {
|
||||
const cipher: Cipher = new Cipher(cipherResponse);
|
||||
|
||||
// If we're in View mode, we don't need to update the form.
|
||||
if (this.params.mode === "view") {
|
||||
return;
|
||||
}
|
||||
cipher.collectionIds = [...this.cipher.collectionIds];
|
||||
|
||||
this.cipherFormComponent().patchCipher((current) => {
|
||||
current.revisionDate = revisionDate;
|
||||
current.archivedDate = archivedDate;
|
||||
return current;
|
||||
});
|
||||
const cipherView = await this.cipherService.decrypt(cipher, userId);
|
||||
|
||||
await this.onCipherSaved(cipherView);
|
||||
};
|
||||
|
||||
archive = async () => {
|
||||
@@ -597,10 +608,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
this.cipher.id as CipherId,
|
||||
activeUserId,
|
||||
);
|
||||
this.updateCipherFromArchive(
|
||||
new Date(cipherResponse.revisionDate),
|
||||
cipherResponse.archivedDate ? new Date(cipherResponse.archivedDate) : null,
|
||||
);
|
||||
|
||||
await this.updateCipherFromResponse(cipherResponse, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
@@ -621,7 +630,9 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
this.cipher.id as CipherId,
|
||||
activeUserId,
|
||||
);
|
||||
this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null);
|
||||
|
||||
await this.updateCipherFromResponse(cipherResponse, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasUnarchived"),
|
||||
@@ -631,7 +642,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
[openApplication]="drawerDetails.invokerId || ''"
|
||||
[checkboxChange]="onCheckboxChange"
|
||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||
class="tw-mb-10"
|
||||
></app-table-row-scrollable-m11>
|
||||
|
||||
@if (emptyTableExplanation()) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<td
|
||||
bitCell
|
||||
[ngClass]="{ 'tw-bg-primary-100': row.applicationName === openApplication }"
|
||||
[attr.aria-label]="'select' | i18n"
|
||||
appStopProp
|
||||
>
|
||||
<input
|
||||
@@ -43,7 +44,7 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="row.applicationName"
|
||||
>
|
||||
@if (row.iconCipher) {
|
||||
<app-vault-icon [cipher]="row.iconCipher" [size]="24"></app-vault-icon>
|
||||
@@ -57,12 +58,15 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="
|
||||
row.applicationName + ' ' + (row.isMarkedAsCritical ? ('criticalBadge' | i18n) : '')
|
||||
"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="tw-max-w-md tw-truncate" [title]="row.applicationName">
|
||||
{{ row.applicationName }}
|
||||
</div>
|
||||
|
||||
@if (row.isMarkedAsCritical) {
|
||||
<span bitBadge [attr.aria-label]="'criticalBadge' | i18n">{{
|
||||
"criticalBadge" | i18n
|
||||
@@ -79,7 +83,7 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="('atRiskPasswords' | i18n) + ' ' + row.atRiskPasswordCount"
|
||||
>
|
||||
<span>
|
||||
{{ row.atRiskPasswordCount }}
|
||||
@@ -94,7 +98,7 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="('totalPasswords' | i18n) + ' ' + row.passwordCount"
|
||||
>
|
||||
<span>
|
||||
{{ row.passwordCount }}
|
||||
@@ -109,7 +113,7 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="('atRiskMembers' | i18n) + ' ' + row.atRiskMemberCount"
|
||||
>
|
||||
<span>
|
||||
{{ row.atRiskMemberCount }}
|
||||
@@ -125,7 +129,7 @@
|
||||
(keydown.space)="showAppAtRiskMembers()(row.applicationName)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="'viewItem' | i18n"
|
||||
[attr.aria-label]="('totalMembers' | i18n) + ' ' + row.memberCount"
|
||||
>
|
||||
{{ row.memberCount }}
|
||||
</td>
|
||||
|
||||
@@ -859,7 +859,6 @@ const safeProviders: SafeProvider[] = [
|
||||
KeyGenerationService,
|
||||
SendStateProviderAbstraction,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract pbkdf2(
|
||||
@@ -17,7 +17,7 @@ export abstract class CryptoFunctionService {
|
||||
iterations: number,
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hkdf(
|
||||
@@ -28,7 +28,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha256" | "sha512",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hkdfExpand(
|
||||
@@ -38,7 +38,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha256" | "sha512",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hash(
|
||||
@@ -46,7 +46,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hmacFast(
|
||||
@@ -56,7 +56,7 @@ export abstract class CryptoFunctionService {
|
||||
): Promise<Uint8Array | string>;
|
||||
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract aesDecryptFastParameters(
|
||||
@@ -66,7 +66,7 @@ export abstract class CryptoFunctionService {
|
||||
key: SymmetricCryptoKey,
|
||||
): CbcDecryptParameters<Uint8Array | string>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract aesDecryptFast({
|
||||
@@ -76,7 +76,7 @@ export abstract class CryptoFunctionService {
|
||||
| { mode: "cbc"; parameters: CbcDecryptParameters<Uint8Array | string> }
|
||||
| { mode: "ecb"; parameters: EcbDecryptParameters<Uint8Array | string> }): Promise<string>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
|
||||
*/
|
||||
abstract aesDecrypt(
|
||||
data: Uint8Array,
|
||||
@@ -85,7 +85,7 @@ export abstract class CryptoFunctionService {
|
||||
mode: "cbc" | "ecb",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract rsaEncrypt(
|
||||
@@ -94,7 +94,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* @deprecated ️️⚠️️ HAZMAT WARNING ⚠️️: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract rsaDecrypt(
|
||||
|
||||
@@ -5,12 +5,20 @@ import { EncString } from "../models/enc-string";
|
||||
export abstract class EncryptService {
|
||||
/**
|
||||
* Encrypts a string to an EncString
|
||||
*
|
||||
* @deprecated NOTE: For new use-cases, prefer using the DataEnvelope inside the SDK instead. This
|
||||
* is both safer and more maintainable.
|
||||
*
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* Encrypts bytes to an EncString
|
||||
*
|
||||
* @deprecated NOTE: You probably do not want to encrypt raw bytes. Please contact the Key-Management team if you think
|
||||
* you need to.
|
||||
*
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
* @deprecated Bytes are not the right abstraction to encrypt in. Use e.g. key wrapping or file encryption instead
|
||||
@@ -129,6 +137,9 @@ export abstract class EncryptService {
|
||||
* Encapsulates a symmetric key with an asymmetric public key
|
||||
* Note: This does not establish sender authenticity
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
|
||||
*
|
||||
* @deprecated NOTE: You probably do not want to use this. Please contact the Key-Management team if you think you need to.
|
||||
*
|
||||
* @param sharedKey - The symmetric key that is to be shared
|
||||
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
|
||||
*/
|
||||
|
||||
@@ -143,17 +143,44 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
|
||||
it("destroys the internal SDK client when all subscriptions are closed", async () => {
|
||||
jest.useFakeTimers();
|
||||
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
|
||||
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
|
||||
await new Promise(process.nextTick);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
subscription_1.unsubscribe();
|
||||
subscription_2.unsubscribe();
|
||||
|
||||
await new Promise(process.nextTick);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mockClient.free).not.toHaveBeenCalled();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(mockClient.free).toHaveBeenCalledTimes(1);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not destroy the internal SDK client if resubscribed within 1 second", async () => {
|
||||
jest.useFakeTimers();
|
||||
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
subscription_1.unsubscribe();
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
expect(mockClient.free).not.toHaveBeenCalled();
|
||||
|
||||
// Resubscribe before the 1 second delay
|
||||
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Client should not be freed since we resubscribed
|
||||
expect(mockClient.free).not.toHaveBeenCalled();
|
||||
expect(sdkClientFactory.createSdkClient).toHaveBeenCalledTimes(1);
|
||||
subscription_2.unsubscribe();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("destroys the internal SDK client when the userKey is unset (i.e. lock or logout)", async () => {
|
||||
@@ -218,6 +245,7 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
|
||||
it("destroys the internal client when an override is set", async () => {
|
||||
jest.useFakeTimers();
|
||||
const mockInternalClient = createMockClient();
|
||||
const mockOverrideClient = createMockClient();
|
||||
sdkClientFactory.createSdkClient.mockResolvedValue(mockInternalClient);
|
||||
@@ -227,7 +255,10 @@ describe("DefaultSdkService", () => {
|
||||
service.setClient(userId, mockOverrideClient);
|
||||
await userClientTracker.pauseUntilReceived(2);
|
||||
|
||||
expect(mockInternalClient.free).not.toHaveBeenCalled();
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(mockInternalClient.free).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("destroys the override client when explicitly setting the client to undefined", async () => {
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
combineLatest,
|
||||
concatMap,
|
||||
Observable,
|
||||
share,
|
||||
shareReplay,
|
||||
ReplaySubject,
|
||||
timer,
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
tap,
|
||||
@@ -263,7 +266,10 @@ export class DefaultSdkService implements SdkService {
|
||||
},
|
||||
),
|
||||
tap({ finalize: () => this.sdkClientCache.delete(userId) }),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
share({
|
||||
connector: () => new ReplaySubject(1),
|
||||
resetOnRefCountZero: () => timer(1000),
|
||||
}),
|
||||
);
|
||||
|
||||
this.sdkClientCache.set(userId, client$);
|
||||
|
||||
@@ -183,8 +183,6 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
|
||||
const response = await this.inFlightApiCalls.sync;
|
||||
|
||||
await this.cipherService.clear(response.profile.id);
|
||||
|
||||
await this.syncUserDecryption(response.profile.id, response.userDecryption);
|
||||
await this.syncProfile(response.profile);
|
||||
await this.syncFolders(response.folders, response.profile.id);
|
||||
|
||||
@@ -23,7 +23,6 @@ export class SendData {
|
||||
deletionDate: string;
|
||||
password: string;
|
||||
emails: string;
|
||||
emailHashes: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
@@ -47,7 +46,6 @@ export class SendData {
|
||||
this.deletionDate = response.deletionDate;
|
||||
this.password = response.password;
|
||||
this.emails = response.emails;
|
||||
this.emailHashes = "";
|
||||
this.disabled = response.disable;
|
||||
this.hideEmail = response.hideEmail;
|
||||
this.authType = response.authType;
|
||||
|
||||
@@ -41,7 +41,6 @@ describe("Send", () => {
|
||||
deletionDate: "2022-01-31T12:00:00.000Z",
|
||||
password: "password",
|
||||
emails: "",
|
||||
emailHashes: "",
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
authType: AuthType.None,
|
||||
@@ -70,8 +69,7 @@ describe("Send", () => {
|
||||
expirationDate: null,
|
||||
deletionDate: null,
|
||||
password: undefined,
|
||||
emails: null,
|
||||
emailHashes: undefined,
|
||||
emails: undefined,
|
||||
disabled: undefined,
|
||||
hideEmail: undefined,
|
||||
});
|
||||
@@ -97,8 +95,7 @@ describe("Send", () => {
|
||||
expirationDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
deletionDate: new Date("2022-01-31T12:00:00.000Z"),
|
||||
password: "password",
|
||||
emails: null,
|
||||
emailHashes: "",
|
||||
emails: "",
|
||||
disabled: false,
|
||||
hideEmail: true,
|
||||
authType: AuthType.None,
|
||||
@@ -173,7 +170,7 @@ describe("Send", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email decryption", () => {
|
||||
describe("Email parsing", () => {
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
@@ -188,91 +185,45 @@ describe("Send", () => {
|
||||
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
|
||||
});
|
||||
|
||||
it("should decrypt and parse single email", async () => {
|
||||
it("should parse single email", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("test@example.com");
|
||||
send.emails = "test@example.com";
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("test@example.com");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey");
|
||||
expect(view.emails).toEqual(["test@example.com"]);
|
||||
});
|
||||
|
||||
it("should decrypt and parse multiple emails", async () => {
|
||||
it("should parse multiple emails", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com");
|
||||
send.emails = "test@example.com,user@test.com,admin@domain.com";
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("test@example.com,user@test.com,admin@domain.com");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace from decrypted emails", async () => {
|
||||
it("should trim whitespace from emails", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc(" test@example.com , user@test.com ");
|
||||
send.emails = " test@example.com , user@test.com ";
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve(" test@example.com , user@test.com ");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual(["test@example.com", "user@test.com"]);
|
||||
});
|
||||
|
||||
@@ -293,61 +244,17 @@ describe("Send", () => {
|
||||
expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey");
|
||||
});
|
||||
|
||||
it("should return empty array when decrypted emails is empty string", async () => {
|
||||
it("should return empty array when emails is empty string", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("");
|
||||
send.emails = "";
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when decrypted emails is null", async () => {
|
||||
const send = new Send();
|
||||
send.id = "id";
|
||||
send.type = SendType.Text;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.key = mockEnc("key");
|
||||
send.emails = mockEnc("something");
|
||||
send.text = mock<SendText>();
|
||||
send.text.decrypt = jest.fn().mockResolvedValue("textView" as any);
|
||||
|
||||
encryptService.decryptString.mockImplementation((encString, key) => {
|
||||
if (encString === send.emails) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (encString === send.name) {
|
||||
return Promise.resolve("name");
|
||||
}
|
||||
if (encString === send.notes) {
|
||||
return Promise.resolve("notes");
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
const view = await send.decrypt(userId);
|
||||
|
||||
expect(view.emails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,7 @@ export class Send extends Domain {
|
||||
expirationDate: Date;
|
||||
deletionDate: Date;
|
||||
password: string;
|
||||
emails: EncString;
|
||||
emailHashes: string;
|
||||
emails: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
authType: AuthType;
|
||||
@@ -52,7 +51,6 @@ export class Send extends Domain {
|
||||
name: null,
|
||||
notes: null,
|
||||
key: null,
|
||||
emails: null,
|
||||
},
|
||||
["id", "accessId"],
|
||||
);
|
||||
@@ -62,13 +60,13 @@ export class Send extends Domain {
|
||||
this.maxAccessCount = obj.maxAccessCount;
|
||||
this.accessCount = obj.accessCount;
|
||||
this.password = obj.password;
|
||||
this.emailHashes = obj.emailHashes;
|
||||
this.disabled = obj.disabled;
|
||||
this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null;
|
||||
this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null;
|
||||
this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null;
|
||||
this.hideEmail = obj.hideEmail;
|
||||
this.authType = obj.authType;
|
||||
this.emails = obj.emails;
|
||||
|
||||
switch (this.type) {
|
||||
case SendType.Text:
|
||||
@@ -100,8 +98,7 @@ export class Send extends Domain {
|
||||
this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null;
|
||||
|
||||
if (this.emails != null) {
|
||||
const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey);
|
||||
model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : [];
|
||||
model.emails = this.emails ? this.emails.split(",").map((e) => e.trim()) : [];
|
||||
} else {
|
||||
model.emails = [];
|
||||
}
|
||||
@@ -133,7 +130,7 @@ export class Send extends Domain {
|
||||
key: EncString.fromJSON(obj.key),
|
||||
name: EncString.fromJSON(obj.name),
|
||||
notes: EncString.fromJSON(obj.notes),
|
||||
emails: EncString.fromJSON(obj.emails),
|
||||
emails: obj.emails,
|
||||
text: SendText.fromJSON(obj.text),
|
||||
file: SendFile.fromJSON(obj.file),
|
||||
revisionDate,
|
||||
|
||||
@@ -8,44 +8,6 @@ import { SendRequest } from "./send.request";
|
||||
|
||||
describe("SendRequest", () => {
|
||||
describe("constructor", () => {
|
||||
it("should populate emails with encrypted string from Send.emails", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("encryptedEmailList");
|
||||
send.emailHashes = "HASH1,HASH2,HASH3";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emails).toBe("encryptedEmailList");
|
||||
});
|
||||
|
||||
it("should populate emailHashes from Send.emailHashes", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("encryptedEmailList");
|
||||
send.emailHashes = "HASH1,HASH2,HASH3";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emailHashes).toBe("HASH1,HASH2,HASH3");
|
||||
});
|
||||
|
||||
it("should set emails to null when Send.emails is null", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
@@ -53,7 +15,6 @@ describe("SendRequest", () => {
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
@@ -63,45 +24,6 @@ describe("SendRequest", () => {
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emails).toBeNull();
|
||||
expect(request.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should handle empty emailHashes", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
expect(request.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should not expose plaintext emails", () => {
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("2.encrypted|emaildata|here");
|
||||
send.emailHashes = "ABC123,DEF456";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
const request = new SendRequest(send);
|
||||
|
||||
// Ensure the request contains the encrypted string format, not plaintext
|
||||
expect(request.emails).toBe("2.encrypted|emaildata|here");
|
||||
expect(request.emails).not.toContain("@");
|
||||
});
|
||||
|
||||
it("should handle name being null", () => {
|
||||
@@ -111,7 +33,6 @@ describe("SendRequest", () => {
|
||||
send.notes = new EncString("encryptedNotes");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
@@ -130,7 +51,6 @@ describe("SendRequest", () => {
|
||||
send.notes = null;
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
@@ -148,7 +68,6 @@ describe("SendRequest", () => {
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
@@ -160,33 +79,4 @@ describe("SendRequest", () => {
|
||||
expect(request.fileLength).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email auth requirements", () => {
|
||||
it("should create request with encrypted emails and plaintext emailHashes", () => {
|
||||
// Setup: A Send with encrypted emails and computed hashes
|
||||
const send = new Send();
|
||||
send.type = SendType.Text;
|
||||
send.name = new EncString("encryptedName");
|
||||
send.key = new EncString("encryptedKey");
|
||||
send.emails = new EncString("2.encryptedEmailString|data");
|
||||
send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes
|
||||
send.disabled = false;
|
||||
send.hideEmail = false;
|
||||
send.text = new SendText();
|
||||
send.text.text = new EncString("text");
|
||||
send.text.hidden = false;
|
||||
|
||||
// Act: Create the request
|
||||
const request = new SendRequest(send);
|
||||
|
||||
// emails field contains encrypted value
|
||||
expect(request.emails).toBe("2.encryptedEmailString|data");
|
||||
expect(request.emails).toContain("encrypted");
|
||||
|
||||
//emailHashes field contains plaintext comma-separated hashes
|
||||
expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8");
|
||||
expect(request.emailHashes).not.toContain("encrypted");
|
||||
expect(request.emailHashes.split(",")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ export class SendRequest {
|
||||
file: SendFileApi;
|
||||
password: string;
|
||||
emails: string;
|
||||
emailHashes: string;
|
||||
disabled: boolean;
|
||||
hideEmail: boolean;
|
||||
|
||||
@@ -32,8 +31,7 @@ export class SendRequest {
|
||||
this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null;
|
||||
this.key = send.key != null ? send.key.encryptedString : null;
|
||||
this.password = send.password;
|
||||
this.emails = send.emails ? send.emails.encryptedString : null;
|
||||
this.emailHashes = send.emailHashes;
|
||||
this.emails = send.emails;
|
||||
this.disabled = send.disabled;
|
||||
this.hideEmail = send.hideEmail;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
@@ -51,7 +50,6 @@ describe("SendService", () => {
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const configService = mock<ConfigService>();
|
||||
let sendStateProvider: SendStateProvider;
|
||||
let sendService: SendService;
|
||||
@@ -98,7 +96,6 @@ describe("SendService", () => {
|
||||
keyGenerationService,
|
||||
sendStateProvider,
|
||||
encryptService,
|
||||
cryptoFunctionService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
@@ -612,111 +609,50 @@ describe("SendService", () => {
|
||||
describe("when SendEmailOTP feature flag is ON", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
cryptoFunctionService.hash.mockClear();
|
||||
});
|
||||
|
||||
describe("email encryption", () => {
|
||||
it("should encrypt emails when email list is provided", async () => {
|
||||
describe("email processing", () => {
|
||||
it("should create a comma separated string when an email list is provided", async () => {
|
||||
sendView.emails = ["test@example.com", "user@test.com"];
|
||||
cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd]));
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(encryptService.encryptString).toHaveBeenCalledWith(
|
||||
"test@example.com,user@test.com",
|
||||
mockCryptoKey,
|
||||
);
|
||||
expect(send.emails).toEqual({ encryptedString: "encrypted" });
|
||||
expect(send.emails).toEqual("test@example.com,user@test.com");
|
||||
expect(send.password).toBeNull();
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is empty", async () => {
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is null", async () => {
|
||||
sendView.emails = null;
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
|
||||
it("should set emails to null when email list is undefined", async () => {
|
||||
sendView.emails = undefined;
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("email hashing", () => {
|
||||
it("should hash emails using SHA-256 and return uppercase hex", async () => {
|
||||
sendView.emails = ["test@example.com"];
|
||||
const mockHash = new Uint8Array([0xab, 0xcd, 0xef]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(send.emailHashes).toBe("ABCDEF");
|
||||
});
|
||||
|
||||
it("should hash multiple emails and return comma-separated hashes", async () => {
|
||||
it("should process multiple emails and return comma-separated string", async () => {
|
||||
sendView.emails = ["test@example.com", "user@test.com"];
|
||||
const mockHash1 = new Uint8Array([0xab, 0xcd]);
|
||||
const mockHash2 = new Uint8Array([0x12, 0x34]);
|
||||
|
||||
cryptoFunctionService.hash
|
||||
.mockResolvedValueOnce(mockHash1)
|
||||
.mockResolvedValueOnce(mockHash2);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
|
||||
expect(send.emailHashes).toBe("ABCD,1234");
|
||||
expect(send.emails).toBe("test@example.com,user@test.com");
|
||||
});
|
||||
|
||||
it("should trim and lowercase emails before hashing", async () => {
|
||||
it("should trim and lowercase emails", async () => {
|
||||
sendView.emails = [" Test@Example.COM ", "USER@test.com"];
|
||||
const mockHash = new Uint8Array([0xff]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256");
|
||||
expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256");
|
||||
});
|
||||
|
||||
it("should set emailHashes to empty string when no emails", async () => {
|
||||
sendView.emails = [];
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
expect(send.emails).toBe("test@example.com,user@test.com");
|
||||
});
|
||||
|
||||
it("should handle single email correctly", async () => {
|
||||
sendView.emails = ["single@test.com"];
|
||||
const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]);
|
||||
|
||||
cryptoFunctionService.hash.mockResolvedValue(mockHash);
|
||||
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emailHashes).toBe("A1B2C3");
|
||||
expect(send.emails).toBe("single@test.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -747,7 +683,6 @@ describe("SendService", () => {
|
||||
describe("when SendEmailOTP feature flag is OFF", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
cryptoFunctionService.hash.mockClear();
|
||||
});
|
||||
|
||||
it("should NOT encrypt emails even when provided", async () => {
|
||||
@@ -756,8 +691,6 @@ describe("SendService", () => {
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use password when provided and flag is OFF", async () => {
|
||||
@@ -769,7 +702,6 @@ describe("SendService", () => {
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBe("hashedPassword");
|
||||
});
|
||||
|
||||
@@ -782,9 +714,7 @@ describe("SendService", () => {
|
||||
const [send] = await sendService.encrypt(sendView, null, "password123");
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBe("hashedPassword");
|
||||
expect(cryptoFunctionService.hash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set emails and password to null when neither provided", async () => {
|
||||
@@ -793,7 +723,6 @@ describe("SendService", () => {
|
||||
const [send] = await sendService.encrypt(sendView, null, null);
|
||||
|
||||
expect(send.emails).toBeNull();
|
||||
expect(send.emailHashes).toBe("");
|
||||
expect(send.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { KeyGenerationService } from "../../../key-management/crypto";
|
||||
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
@@ -54,7 +53,6 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private stateProvider: SendStateProvider,
|
||||
private encryptService: EncryptService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -91,13 +89,13 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
const hasEmails = (model.emails?.length ?? 0) > 0;
|
||||
|
||||
if (sendEmailOTPEnabled && hasEmails) {
|
||||
const plaintextEmails = model.emails.join(",");
|
||||
send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey);
|
||||
send.emailHashes = await this.hashEmails(plaintextEmails);
|
||||
send.emails = model.emails
|
||||
.map((e) => e.trim())
|
||||
.join(",")
|
||||
.toLocaleLowerCase();
|
||||
send.password = null;
|
||||
} else {
|
||||
send.emails = null;
|
||||
send.emailHashes = "";
|
||||
|
||||
if (password != null) {
|
||||
// Note: Despite being called key, the passwordKey is not used for encryption.
|
||||
@@ -393,19 +391,4 @@ export class SendService implements InternalSendServiceAbstraction {
|
||||
decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
return decryptedSends;
|
||||
}
|
||||
|
||||
private async hashEmails(emails: string): Promise<string> {
|
||||
if (!emails) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const emailArray = emails.split(",").map((e) => e.trim().toLowerCase());
|
||||
const hashPromises = emailArray.map(async (email) => {
|
||||
const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256");
|
||||
return Utils.fromBufferToHex(hash).toUpperCase();
|
||||
});
|
||||
|
||||
const hashes = await Promise.all(hashPromises);
|
||||
return hashes.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export function createSendData(value: Partial<SendData> = {}) {
|
||||
deletionDate: "2024-09-04",
|
||||
password: "password",
|
||||
emails: "",
|
||||
emailHashes: "",
|
||||
disabled: false,
|
||||
hideEmail: false,
|
||||
};
|
||||
@@ -66,7 +65,6 @@ export function testSendData(id: string, name: string) {
|
||||
data.notes = "Notes!!";
|
||||
data.key = null;
|
||||
data.emails = "";
|
||||
data.emailHashes = "";
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -82,7 +80,6 @@ export function testSend(id: string, name: string) {
|
||||
data.deletionDate = null;
|
||||
data.notes = new EncString("Notes!!");
|
||||
data.key = null;
|
||||
data.emails = null;
|
||||
data.emailHashes = "";
|
||||
data.emails = "";
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Observable } from "rxjs";
|
||||
|
||||
import { SendView } from "../../tools/send/models/view/send.view";
|
||||
import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class SearchService {
|
||||
@@ -19,7 +20,7 @@ export abstract class SearchService {
|
||||
abstract isSearchable(userId: UserId, query: string | null): Promise<boolean>;
|
||||
abstract indexCiphers(
|
||||
userId: UserId,
|
||||
ciphersToIndex: CipherViewLike[],
|
||||
ciphersToIndex: CipherView[],
|
||||
indexedEntityGuid?: string,
|
||||
): Promise<void>;
|
||||
abstract searchCiphers<C extends CipherViewLike>(
|
||||
|
||||
@@ -173,14 +173,13 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
decryptStartTime = performance.now();
|
||||
}),
|
||||
switchMap(async (ciphers) => {
|
||||
return await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false);
|
||||
void this.setFailedDecryptedCiphers(failures, userId);
|
||||
// Trigger full decryption and indexing in background
|
||||
void this.getAllDecrypted(userId);
|
||||
return decrypted;
|
||||
}),
|
||||
tap(([decrypted, failures]) => {
|
||||
void Promise.all([
|
||||
this.setFailedDecryptedCiphers(failures, userId),
|
||||
this.searchService.indexCiphers(userId, decrypted),
|
||||
]);
|
||||
|
||||
tap((decrypted) => {
|
||||
this.logService.measure(
|
||||
decryptStartTime,
|
||||
"Vault",
|
||||
@@ -189,11 +188,10 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
[["Items", decrypted.length]],
|
||||
);
|
||||
}),
|
||||
map(([decrypted]) => decrypted),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}, this.clearCipherViewsForUser$);
|
||||
});
|
||||
|
||||
/**
|
||||
* Observable that emits an array of decrypted ciphers for the active user.
|
||||
|
||||
@@ -91,21 +91,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
const responseDataArray = response.data.map(
|
||||
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
|
||||
);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
localCipher.archivedDate = cipher.archivedDate;
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
await this.cipherService.upsert(responseDataArray, userId);
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
@@ -115,21 +105,11 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
const responseDataArray = response.data.map(
|
||||
(cipher) => new CipherData(cipher, currentCiphers[cipher.id as CipherId]?.collectionIds),
|
||||
);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
localCipher.archivedDate = cipher.archivedDate;
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
await this.cipherService.upsert(responseDataArray, userId);
|
||||
return response.data[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IndexedEntityId, UserId } from "../../types/guid";
|
||||
import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service";
|
||||
import { FieldType } from "../enums";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils";
|
||||
|
||||
// Time to wait before performing a search after the user stops typing.
|
||||
@@ -168,7 +169,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
|
||||
async indexCiphers(
|
||||
userId: UserId,
|
||||
ciphers: CipherViewLike[],
|
||||
ciphers: CipherView[],
|
||||
indexedEntityId?: string,
|
||||
): Promise<void> {
|
||||
if (await this.getIsIndexing(userId)) {
|
||||
@@ -181,47 +182,34 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
const builder = new lunr.Builder();
|
||||
builder.pipeline.add(this.normalizeAccentsPipelineFunction);
|
||||
builder.ref("id");
|
||||
builder.field("shortid", {
|
||||
boost: 100,
|
||||
extractor: (c: CipherViewLike) => uuidAsString(c.id).substr(0, 8),
|
||||
});
|
||||
builder.field("shortid", { boost: 100, extractor: (c: CipherView) => c.id.substr(0, 8) });
|
||||
builder.field("name", {
|
||||
boost: 10,
|
||||
});
|
||||
builder.field("subtitle", {
|
||||
boost: 5,
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const subtitle = CipherViewLikeUtils.subtitle(c);
|
||||
if (subtitle != null && CipherViewLikeUtils.getType(c) === CipherType.Card) {
|
||||
return subtitle.replace(/\*/g, "");
|
||||
extractor: (c: CipherView) => {
|
||||
if (c.subTitle != null && c.type === CipherType.Card) {
|
||||
return c.subTitle.replace(/\*/g, "");
|
||||
}
|
||||
return subtitle;
|
||||
return c.subTitle;
|
||||
},
|
||||
});
|
||||
builder.field("notes", { extractor: (c: CipherViewLike) => CipherViewLikeUtils.getNotes(c) });
|
||||
builder.field("notes");
|
||||
builder.field("login.username", {
|
||||
extractor: (c: CipherViewLike) => {
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
return login?.username ?? null;
|
||||
},
|
||||
});
|
||||
builder.field("login.uris", {
|
||||
boost: 2,
|
||||
extractor: (c: CipherViewLike) => this.uriExtractor(c),
|
||||
});
|
||||
builder.field("fields", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, false),
|
||||
});
|
||||
builder.field("fields_joined", {
|
||||
extractor: (c: CipherViewLike) => this.fieldExtractor(c, true),
|
||||
extractor: (c: CipherView) =>
|
||||
c.type === CipherType.Login && c.login != null ? c.login.username : null,
|
||||
});
|
||||
builder.field("login.uris", { boost: 2, extractor: (c: CipherView) => this.uriExtractor(c) });
|
||||
builder.field("fields", { extractor: (c: CipherView) => this.fieldExtractor(c, false) });
|
||||
builder.field("fields_joined", { extractor: (c: CipherView) => this.fieldExtractor(c, true) });
|
||||
builder.field("attachments", {
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, false),
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, false),
|
||||
});
|
||||
builder.field("attachments_joined", {
|
||||
extractor: (c: CipherViewLike) => this.attachmentExtractor(c, true),
|
||||
extractor: (c: CipherView) => this.attachmentExtractor(c, true),
|
||||
});
|
||||
builder.field("organizationid", { extractor: (c: CipherViewLike) => c.organizationId });
|
||||
builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId });
|
||||
ciphers = ciphers || [];
|
||||
ciphers.forEach((c) => builder.add(c));
|
||||
const index = builder.build();
|
||||
@@ -412,44 +400,37 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return await firstValueFrom(this.searchIsIndexing$(userId));
|
||||
}
|
||||
|
||||
private fieldExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const fields = CipherViewLikeUtils.getFields(c);
|
||||
if (!fields || fields.length === 0) {
|
||||
private fieldExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasFields) {
|
||||
return null;
|
||||
}
|
||||
let fieldStrings: string[] = [];
|
||||
fields.forEach((f) => {
|
||||
let fields: string[] = [];
|
||||
c.fields.forEach((f) => {
|
||||
if (f.name != null) {
|
||||
fieldStrings.push(f.name);
|
||||
fields.push(f.name);
|
||||
}
|
||||
// For CipherListView, value is only populated for Text fields
|
||||
// For CipherView, we check the type explicitly
|
||||
if (f.value != null) {
|
||||
const fieldType = (f as { type?: FieldType }).type;
|
||||
if (fieldType === undefined || fieldType === FieldType.Text) {
|
||||
fieldStrings.push(f.value);
|
||||
}
|
||||
if (f.type === FieldType.Text && f.value != null) {
|
||||
fields.push(f.value);
|
||||
}
|
||||
});
|
||||
fieldStrings = fieldStrings.filter((f) => f.trim() !== "");
|
||||
if (fieldStrings.length === 0) {
|
||||
fields = fields.filter((f) => f.trim() !== "");
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return joined ? fieldStrings.join(" ") : fieldStrings;
|
||||
return joined ? fields.join(" ") : fields;
|
||||
}
|
||||
|
||||
private attachmentExtractor(c: CipherViewLike, joined: boolean) {
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(c);
|
||||
if (!attachmentNames || attachmentNames.length === 0) {
|
||||
private attachmentExtractor(c: CipherView, joined: boolean) {
|
||||
if (!c.hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
let attachments: string[] = [];
|
||||
attachmentNames.forEach((fileName) => {
|
||||
if (fileName != null) {
|
||||
if (joined && fileName.indexOf(".") > -1) {
|
||||
attachments.push(fileName.substring(0, fileName.lastIndexOf(".")));
|
||||
c.attachments.forEach((a) => {
|
||||
if (a != null && a.fileName != null) {
|
||||
if (joined && a.fileName.indexOf(".") > -1) {
|
||||
attachments.push(a.fileName.substr(0, a.fileName.lastIndexOf(".")));
|
||||
} else {
|
||||
attachments.push(fileName);
|
||||
attachments.push(a.fileName);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -460,39 +441,43 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
return joined ? attachments.join(" ") : attachments;
|
||||
}
|
||||
|
||||
private uriExtractor(c: CipherViewLike) {
|
||||
if (CipherViewLikeUtils.getType(c) !== CipherType.Login) {
|
||||
return null;
|
||||
}
|
||||
const login = CipherViewLikeUtils.getLogin(c);
|
||||
if (!login?.uris?.length) {
|
||||
private uriExtractor(c: CipherView) {
|
||||
if (c.type !== CipherType.Login || c.login == null || !c.login.hasUris) {
|
||||
return null;
|
||||
}
|
||||
const uris: string[] = [];
|
||||
login.uris.forEach((u) => {
|
||||
c.login.uris.forEach((u) => {
|
||||
if (u.uri == null || u.uri === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract port from URI
|
||||
// Match ports
|
||||
const portMatch = u.uri.match(/:(\d+)(?:[/?#]|$)/);
|
||||
const port = portMatch?.[1];
|
||||
|
||||
const hostname = CipherViewLikeUtils.getUriHostname(u);
|
||||
if (hostname !== undefined) {
|
||||
uris.push(hostname);
|
||||
let uri = u.uri;
|
||||
|
||||
if (u.hostname !== null) {
|
||||
uris.push(u.hostname);
|
||||
if (port) {
|
||||
uris.push(`${hostname}:${port}`);
|
||||
uris.push(`${u.hostname}:${port}`);
|
||||
uris.push(port);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const slash = uri.indexOf("/");
|
||||
const hostPart = slash > -1 ? uri.substring(0, slash) : uri;
|
||||
uris.push(hostPart);
|
||||
if (port) {
|
||||
uris.push(`${hostPart}`);
|
||||
uris.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Add processed URI (strip protocol and query params for non-regex matches)
|
||||
let uri = u.uri;
|
||||
if (u.match !== UriMatchStrategy.RegularExpression) {
|
||||
const protocolIndex = uri.indexOf("://");
|
||||
if (protocolIndex > -1) {
|
||||
uri = uri.substring(protocolIndex + 3);
|
||||
uri = uri.substr(protocolIndex + 3);
|
||||
}
|
||||
const queryIndex = uri.search(/\?|&|#/);
|
||||
if (queryIndex > -1) {
|
||||
@@ -501,7 +486,6 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
uris.push(uri);
|
||||
});
|
||||
|
||||
return uris.length > 0 ? uris : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -651,198 +651,4 @@ describe("CipherViewLikeUtils", () => {
|
||||
expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotes", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = "This is a test note";
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBe("This is a test note");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.notes = undefined;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns notes when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
notes: "List view notes",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBe("List view notes");
|
||||
});
|
||||
|
||||
it("returns undefined when notes are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getNotes(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFields", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [
|
||||
{ name: "Field1", value: "Value1" } as any,
|
||||
{ name: "Field2", value: "Value2" } as any,
|
||||
];
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Field1");
|
||||
expect(fields?.[0].value).toBe("Value1");
|
||||
expect(fields?.[1].name).toBe("Field2");
|
||||
expect(fields?.[1].value).toBe("Value2");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.fields = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns fields when present", () => {
|
||||
const cipherListView = {
|
||||
type: { login: {} },
|
||||
fields: [
|
||||
{ name: "Username", value: "user@example.com" },
|
||||
{ name: "API Key", value: "abc123" },
|
||||
],
|
||||
} as CipherListView;
|
||||
|
||||
const fields = CipherViewLikeUtils.getFields(cipherListView);
|
||||
|
||||
expect(fields).toHaveLength(2);
|
||||
expect(fields?.[0].name).toBe("Username");
|
||||
expect(fields?.[0].value).toBe("user@example.com");
|
||||
expect(fields?.[1].name).toBe("API Key");
|
||||
expect(fields?.[1].value).toBe("abc123");
|
||||
});
|
||||
|
||||
it("returns empty array when fields array is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
fields: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when fields are not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getFields(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentNames", () => {
|
||||
describe("CipherView", () => {
|
||||
it("returns attachment filenames when present", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "document.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = "image.png";
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = "spreadsheet.xlsx";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["document.pdf", "image.png", "spreadsheet.xlsx"]);
|
||||
});
|
||||
|
||||
it("filters out null and undefined filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
attachment1.fileName = "valid.pdf";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
attachment2.fileName = null as any;
|
||||
const attachment3 = new AttachmentView();
|
||||
attachment3.id = "3";
|
||||
attachment3.fileName = undefined;
|
||||
const attachment4 = new AttachmentView();
|
||||
attachment4.id = "4";
|
||||
attachment4.fileName = "another.txt";
|
||||
cipherView.attachments = [attachment1, attachment2, attachment3, attachment4];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual(["valid.pdf", "another.txt"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachments have no filenames", () => {
|
||||
const cipherView = createCipherView();
|
||||
const attachment1 = new AttachmentView();
|
||||
attachment1.id = "1";
|
||||
const attachment2 = new AttachmentView();
|
||||
attachment2.id = "2";
|
||||
cipherView.attachments = [attachment1, attachment2];
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherView);
|
||||
|
||||
expect(attachmentNames).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty attachments array", () => {
|
||||
const cipherView = createCipherView();
|
||||
cipherView.attachments = [];
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherView)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CipherListView", () => {
|
||||
it("returns attachment names when present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: ["report.pdf", "photo.jpg", "data.csv"],
|
||||
} as CipherListView;
|
||||
|
||||
const attachmentNames = CipherViewLikeUtils.getAttachmentNames(cipherListView);
|
||||
|
||||
expect(attachmentNames).toEqual(["report.pdf", "photo.jpg", "data.csv"]);
|
||||
});
|
||||
|
||||
it("returns empty array when attachmentNames is empty", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
attachmentNames: [],
|
||||
} as unknown as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns undefined when attachmentNames is not present", () => {
|
||||
const cipherListView = {
|
||||
type: "secureNote",
|
||||
} as CipherListView;
|
||||
|
||||
expect(CipherViewLikeUtils.getAttachmentNames(cipherListView)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
LoginUriView as LoginListUriView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CipherType } from "../enums";
|
||||
import { Cipher } from "../models/domain/cipher";
|
||||
import { CardView } from "../models/view/card.view";
|
||||
@@ -291,71 +290,6 @@ export class CipherViewLikeUtils {
|
||||
static decryptionFailure = (cipher: CipherViewLike): boolean => {
|
||||
return "decryptionFailure" in cipher ? cipher.decryptionFailure : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the notes from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract notes from (either `CipherView` or `CipherListView`)
|
||||
* @returns The notes string if present, or `undefined` if not set
|
||||
*/
|
||||
static getNotes = (cipher: CipherViewLike): string | undefined => {
|
||||
return cipher.notes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the fields from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract fields from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of field objects with `name` and `value` properties, `undefined` if not set
|
||||
*/
|
||||
static getFields = (
|
||||
cipher: CipherViewLike,
|
||||
): { name?: string | null; value?: string | undefined }[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.fields;
|
||||
}
|
||||
return cipher.fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns attachment filenames from the cipher.
|
||||
*
|
||||
* @param cipher - The cipher to extract attachment names from (either `CipherView` or `CipherListView`)
|
||||
* @returns Array of attachment filenames, `undefined` if attachments are not present
|
||||
*/
|
||||
static getAttachmentNames = (cipher: CipherViewLike): string[] | undefined => {
|
||||
if (this.isCipherListView(cipher)) {
|
||||
return cipher.attachmentNames;
|
||||
}
|
||||
|
||||
return cipher.attachments
|
||||
?.map((a) => a.fileName)
|
||||
.filter((name): name is string => name != null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts hostname from a login URI.
|
||||
*
|
||||
* @param uri - The URI object (either `LoginUriView` class or `LoginListUriView`)
|
||||
* @returns The hostname if available, `undefined` otherwise
|
||||
*
|
||||
* @remarks
|
||||
* - For `LoginUriView` (CipherView): Uses the built-in `hostname` getter
|
||||
* - For `LoginListUriView` (CipherListView): Computes hostname using `Utils.getHostname()`
|
||||
* - Returns `undefined` for RegularExpression match types or when hostname cannot be extracted
|
||||
*/
|
||||
static getUriHostname = (uri: LoginListUriView | LoginUriView): string | undefined => {
|
||||
if ("hostname" in uri && typeof uri.hostname !== "undefined") {
|
||||
return uri.hostname ?? undefined;
|
||||
}
|
||||
|
||||
if (uri.match !== UriMatchStrategy.RegularExpression && uri.uri) {
|
||||
const hostname = Utils.getHostname(uri.uri);
|
||||
return hostname === "" ? undefined : hostname;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<bit-simple-dialog>
|
||||
<i
|
||||
<bit-icon
|
||||
bitDialogIcon
|
||||
class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
name="bwi-exclamation-triangle"
|
||||
class="tw-text-warning tw-text-3xl"
|
||||
></bit-icon>
|
||||
|
||||
<span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span>
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
{{ "goBack" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
|
||||
{{ "howToManageMyVault" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
|
||||
</a>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogService,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
@@ -35,7 +36,7 @@ export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfir
|
||||
@Component({
|
||||
templateUrl: "./leave-confirmation-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
|
||||
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
|
||||
})
|
||||
export class LeaveConfirmationDialogComponent {
|
||||
private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA);
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
{{ "declineAndLeave" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
|
||||
{{ "whyAmISeeingThis" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
|
||||
</a>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogService,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
IconModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
@@ -35,7 +36,7 @@ export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDi
|
||||
@Component({
|
||||
templateUrl: "./transfer-items-dialog.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
|
||||
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
|
||||
})
|
||||
export class TransferItemsDialogComponent {
|
||||
private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA);
|
||||
|
||||
Reference in New Issue
Block a user