1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 11:43:51 +00:00

Merge remote-tracking branch 'origin' into auth/pm-14943/auth-request-extension-dialog-approve

This commit is contained in:
Patrick Pimentel
2025-08-28 15:40:34 -04:00
78 changed files with 947 additions and 737 deletions

View File

@@ -1299,6 +1299,7 @@ jobs:
$package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json
$package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER"
$package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json
Write-Output "### MacOS App Store build number: $env:BUILD_NUMBER"
- name: Install Node dependencies
@@ -1374,6 +1375,23 @@ jobs:
CSC_FOR_PULL_REQUEST: true
run: npm run pack:mac:mas
- name: Create MacOS App Store build number artifact
shell: pwsh
env:
BUILD_NUMBER: ${{ needs.setup.outputs.build_number }}
run: |
$buildInfo = @{
buildNumber = $env:BUILD_NUMBER
}
$buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json
- name: Upload MacOS App Store build number artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: macos-build-number.json
path: apps/desktop/dist/macos-build-number.json
if-no-files-found: error
- name: Upload .pkg artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:

View File

@@ -18,10 +18,15 @@ on:
type: string
default: latest
electron_rollout_percentage:
description: 'Staged Rollout Percentage for Electron'
required: true
description: 'Staged Rollout Percentage for Electron (ignored if Electron publish disabled)'
required: false
default: '10'
type: string
electron_publish:
description: 'Publish to Electron (auto-updater)'
required: true
default: true
type: boolean
snap_publish:
description: 'Publish to Snap store'
required: true
@@ -32,6 +37,15 @@ on:
required: true
default: true
type: boolean
mas_publish:
description: 'Publish to Mac App Store'
required: true
default: true
type: boolean
release_notes:
description: 'Release Notes'
required: false
type: string
jobs:
setup:
@@ -71,7 +85,7 @@ jobs:
echo "Release Version: ${{ inputs.version }}"
echo "version=${{ inputs.version }}"
$TAG_NAME="desktop-v${{ inputs.version }}"
TAG_NAME="desktop-v${{ inputs.version }}"
echo "Tag name: $TAG_NAME"
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
@@ -109,6 +123,7 @@ jobs:
name: Electron blob publish
runs-on: ubuntu-22.04
needs: setup
if: inputs.electron_publish
permissions:
contents: read
packages: read
@@ -292,6 +307,92 @@ jobs:
run: choco push --source=https://push.chocolatey.org/
working-directory: apps/desktop/dist
mas:
name: Deploy Mac App Store
runs-on: macos-15
needs: setup
permissions:
contents: read
id-token: write
if: inputs.mas_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
_RELEASE_TAG: ${{ needs.setup.outputs.tag_name }}
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate release notes for MAS
if: inputs.mas_publish && (inputs.release_notes == '' || inputs.release_notes == null)
run: |
echo "❌ Release notes are required when publishing to Mac App Store"
echo "Please provide release notes using the 'Release Notes' input field"
exit 1
- name: Download MacOS App Store build number
working-directory: apps/desktop
run: wget https://github.com/bitwarden/clients/releases/download/${{ env._RELEASE_TAG }}/macos-build-number.json
- name: Setup Ruby and Install Fastlane
uses: ruby/setup-ruby@ca041f971d66735f3e5ff1e21cc13e2d51e7e535 # v1.233.0
with:
ruby-version: '3.0'
bundler-cache: false
working-directory: apps/desktop
- name: Install Fastlane
working-directory: apps/desktop
run: gem install fastlane
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Publish to App Store
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
working-directory: apps/desktop
run: |
BUILD_NUMBER=$(jq -r '.buildNumber' macos-build-number.json)
CHANGELOG="${{ inputs.release_notes }}"
IS_DRY_RUN="${{ inputs.publish_type == 'Dry Run' }}"
if [ "$IS_DRY_RUN" = "true" ]; then
echo "🧪 DRY RUN MODE - Testing without actual App Store submission"
echo "📦 Would publish build $BUILD_NUMBER to Mac App Store"
else
echo "🚀 PRODUCTION MODE - Publishing to Mac App Store"
echo "📦 Publishing build $BUILD_NUMBER to Mac App Store"
fi
echo "📝 Release notes (${#CHANGELOG} chars): ${CHANGELOG:0:100}..."
# Validate changelog length (App Store limit is 4000 chars)
if [ ${#CHANGELOG} -gt 4000 ]; then
echo "❌ Release notes too long: ${#CHANGELOG} characters (max 4000)"
exit 1
fi
fastlane publish --verbose \
app_version:"${{ env._PKG_VERSION }}" \
build_number:$BUILD_NUMBER \
changelog:"$CHANGELOG" \
dry_run:$IS_DRY_RUN
update-deployment:
name: Update Deployment Status
runs-on: ubuntu-22.04
@@ -300,6 +401,7 @@ jobs:
- electron-blob
- snap
- choco
- mas
permissions:
contents: read
deployments: write

View File

@@ -124,7 +124,8 @@ jobs:
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}.yml,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-linux.yml,
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml"
apps/desktop/artifacts/${{ env.RELEASE_CHANNEL }}-mac.yml,
apps/desktop/artifacts/macos-build-number.json"
commit: ${{ github.sha }}
tag: desktop-v${{ env.PKG_VERSION }}
name: Desktop v${{ env.PKG_VERSION }}

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.8.1",
"version": "2025.8.2",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -1760,14 +1760,8 @@
"popupU2fCloseMessage": {
"message": "This browser cannot process U2F requests in this popup window. Do you want to open this popup in a new window so that you can log in using U2F?"
},
"enableFavicon": {
"message": "Show website icons"
},
"faviconDesc": {
"message": "Show a recognizable image next to each login."
},
"faviconDescAlt": {
"message": "Show a recognizable image next to each login. Applies to all logged in accounts."
"showIconsChangePasswordUrls": {
"message": "Show website icons and retrieve change password URLs"
},
"enableBadgeCounter": {
"message": "Show badge counter"
@@ -5575,6 +5569,12 @@
"message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.",
"description": "Aria label for the body content of the generator nudge"
},
"aboutThisSetting": {
"message": "About this setting"
},
"permitCipherDetailsDescription": {
"message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service."
},
"noPermissionsViewPage": {
"message": "You do not have permissions to view this page. Try logging in with a different account."
},

View File

@@ -9,5 +9,8 @@ export type InlineMenuExtensionMessageHandlers = {
export interface AutofillInlineMenuContentService {
messageHandlers: InlineMenuExtensionMessageHandlers;
isElementInlineMenu(element: HTMLElement): boolean;
getOwnedTagNames: () => string[];
getUnownedTopLayerItems: (includeCandidates?: boolean) => NodeListOf<Element>;
refreshTopLayerPosition: () => void;
destroy(): void;
}

View File

@@ -42,9 +42,6 @@ describe("AutofillInlineMenuContentService", () => {
"sendExtensionMessage",
);
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque");
jest
.spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse")
.mockResolvedValue(false);
});
afterEach(() => {
@@ -390,20 +387,6 @@ describe("AutofillInlineMenuContentService", () => {
expect(globalThis.document.body.insertBefore).not.toHaveBeenCalled();
});
it("closes the inline menu if the page has content in the top layer", async () => {
document.querySelector("html").style.opacity = "1";
document.body.style.opacity = "1";
jest
.spyOn(autofillInlineMenuContentService as any, "getPageTopLayerInUse")
.mockResolvedValue(true);
await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]);
expect(autofillInlineMenuContentService["getPageIsOpaque"]).toHaveReturnedWith(true);
expect(autofillInlineMenuContentService["closeInlineMenu"]).toHaveBeenCalled();
});
it("closes the inline menu if the page body is not sufficiently opaque", async () => {
document.querySelector("html").style.opacity = "0.9";
document.body.style.opacity = "0";

View File

@@ -159,6 +159,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (!(await this.isInlineMenuButtonVisible())) {
this.appendInlineMenuElementToDom(this.buttonElement);
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.Button, true);
this.buttonElement.showPopover();
}
}
@@ -174,6 +175,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (!(await this.isInlineMenuListVisible())) {
this.appendInlineMenuElementToDom(this.listElement);
this.updateInlineMenuElementIsVisibleStatus(AutofillOverlayElement.List, true);
this.listElement.showPopover();
}
}
@@ -219,6 +221,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private createButtonElement() {
if (this.isFirefoxBrowser) {
this.buttonElement = globalThis.document.createElement("div");
this.buttonElement.setAttribute("popover", "manual");
new AutofillInlineMenuButtonIframe(this.buttonElement);
return;
@@ -235,6 +238,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
},
);
this.buttonElement = globalThis.document.createElement(customElementName);
this.buttonElement.setAttribute("popover", "manual");
}
/**
@@ -244,6 +248,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private createListElement() {
if (this.isFirefoxBrowser) {
this.listElement = globalThis.document.createElement("div");
this.listElement.setAttribute("popover", "manual");
new AutofillInlineMenuListIframe(this.listElement);
return;
@@ -260,6 +265,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
},
);
this.listElement = globalThis.document.createElement(customElementName);
this.listElement.setAttribute("popover", "manual");
}
/**
@@ -293,6 +299,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
this.containerElementMutationObserver = new MutationObserver(
this.handleContainerElementMutationObserverUpdate,
);
this.observePageAttributes();
};
/**
@@ -300,9 +308,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
* elements are not modified by the website.
*/
private observeCustomElements() {
this.htmlMutationObserver?.observe(document.querySelector("html"), { attributes: true });
this.bodyMutationObserver?.observe(document.body, { attributes: true });
if (this.buttonElement) {
this.inlineMenuElementsMutationObserver?.observe(this.buttonElement, {
attributes: true,
@@ -314,6 +319,25 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
}
}
/**
* Sets up mutation observers to verify that the page `html` and `body` attributes
* are not altered in a way that would impact safe display of the inline menu.
*/
private observePageAttributes() {
if (document.documentElement) {
this.htmlMutationObserver?.observe(document.documentElement, { attributes: true });
}
if (document.body) {
this.bodyMutationObserver?.observe(document.body, { attributes: true });
}
}
private unobservePageAttributes() {
this.htmlMutationObserver?.disconnect();
this.bodyMutationObserver?.disconnect();
}
/**
* Disconnects the mutation observers that are used to verify that the inline menu
* elements are not modified by the website.
@@ -405,9 +429,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private checkPageRisks = async () => {
const pageIsOpaque = await this.getPageIsOpaque();
const pageTopLayerInUse = await this.getPageTopLayerInUse();
const risksFound = !pageIsOpaque || pageTopLayerInUse;
const risksFound = !pageIsOpaque;
if (risksFound) {
this.closeInlineMenu();
@@ -426,12 +449,61 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
};
/**
* Checks if the page top layer has content (will obscure/overlap the inline menu)
* Returns the name of the generated container tags for usage internally to avoid
* unintentional targeting of the owned experience.
*/
private getPageTopLayerInUse = () => {
const pageHasOpenPopover = !!globalThis.document.querySelector(":popover-open");
getOwnedTagNames = (): string[] => {
return [
...(this.buttonElement?.tagName ? [this.buttonElement.tagName] : []),
...(this.listElement?.tagName ? [this.listElement.tagName] : []),
];
};
return pageHasOpenPopover;
/**
* Queries and return elements (excluding those of the inline menu) that exist in the
* top-layer via popover or dialog
* @param {boolean} [includeCandidates=false] indicate whether top-layer candidate (which
* may or may not be active) should be included in the query
*/
getUnownedTopLayerItems = (includeCandidates = false) => {
const inlineMenuTagExclusions = [
...(this.buttonElement?.tagName ? [`:not(${this.buttonElement.tagName})`] : []),
...(this.listElement?.tagName ? [`:not(${this.listElement.tagName})`] : []),
":popover-open",
].join("");
const selector = [
":modal",
inlineMenuTagExclusions,
...(includeCandidates ? ["[popover], dialog"] : []),
].join(",");
const otherTopLayeritems = globalThis.document.querySelectorAll(selector);
return otherTopLayeritems;
};
refreshTopLayerPosition = () => {
const otherTopLayerItems = this.getUnownedTopLayerItems();
// No need to refresh if there are no other top-layer items
if (!otherTopLayerItems.length) {
return;
}
const buttonInDocument =
this.buttonElement &&
(globalThis.document.getElementsByTagName(this.buttonElement.tagName)[0] as HTMLElement);
const listInDocument =
this.listElement &&
(globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement);
if (buttonInDocument) {
buttonInDocument.hidePopover();
buttonInDocument.showPopover();
}
if (listInDocument) {
listInDocument.hidePopover();
listInDocument.showPopover();
}
};
/**
@@ -443,12 +515,17 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
private getPageIsOpaque = () => {
// These are computed style values, so we don't need to worry about non-float values
// for `opacity`, here
const htmlOpacity = globalThis.window.getComputedStyle(
globalThis.document.querySelector("html"),
).opacity;
const bodyOpacity = globalThis.window.getComputedStyle(
globalThis.document.querySelector("body"),
).opacity;
// @TODO for definitive checks, traverse up the node tree from the inline menu container;
// nodes can exist between `html` and `body`
const htmlElement = globalThis.document.querySelector("html");
const bodyElement = globalThis.document.querySelector("body");
if (!htmlElement || !bodyElement) {
return false;
}
const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0";
const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0";
// Any value above this is considered "opaque" for our purposes
const opacityThreshold = 0.6;
@@ -607,5 +684,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
destroy() {
this.closeInlineMenu();
this.clearPersistentLastChildOverrideTimeout();
this.unobservePageAttributes();
}
}

View File

@@ -8,7 +8,7 @@ exports[`OverlayNotificationsContentService opening the notification bar creates
<iframe
id="bit-notification-bar-iframe"
src="chrome-extension://id/notification/bar.html"
style="width: 100% !important; height: 100% !important; border: 0px !important; display: block !important; position: relative !important; transition: transform 0.15s ease-out, opacity 0.15s ease !important; border-radius: 4px !important; color-scheme: normal !important; transform: translateX(0) !important; opacity: 0;"
style="width: 100% !important; height: 100% !important; border: 0px !important; display: block !important; position: relative !important; transition: transform 0.15s ease-out, opacity 0.15s ease !important; border-radius: 4px !important; color-scheme: auto !important; transform: translateX(0) !important; opacity: 0;"
/>
</div>
`;

View File

@@ -55,7 +55,7 @@ export class OverlayNotificationsContentService
position: "relative",
transition: "transform 0.15s ease-out, opacity 0.15s ease",
borderRadius: "4px",
colorScheme: "normal",
colorScheme: "auto",
};
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
openNotificationBar: ({ message }) => this.handleOpenNotificationBarMessage(message),

View File

@@ -39,6 +39,9 @@ export interface AutofillOverlayContentService {
pageDetails: AutofillPageDetails,
): Promise<void>;
blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void;
getOwnedInlineMenuTagNames(): string[];
getUnownedTopLayerItems(includeCandidates?: boolean): NodeListOf<Element> | undefined;
refreshMenuLayerPosition(): void;
clearUserFilledFields(): void;
destroy(): void;
}

View File

@@ -225,6 +225,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
}
refreshMenuLayerPosition = () => this.inlineMenuContentService?.refreshTopLayerPosition();
getOwnedInlineMenuTagNames = () => this.inlineMenuContentService?.getOwnedTagNames() || [];
getUnownedTopLayerItems = (includeCandidates?: boolean) =>
this.inlineMenuContentService?.getUnownedTopLayerItems(includeCandidates);
/**
* Clears all cached user filled fields.
*/

View File

@@ -14,7 +14,7 @@ import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/au
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum";
import { FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -2987,12 +2987,6 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "5";
}
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
@@ -3003,23 +2997,6 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]);
});
});
it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]);
});
});
const extraExpectedDateFormats = [
@@ -3092,12 +3069,6 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "05";
}
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
@@ -3108,23 +3079,6 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]);
});
});
it("feature-flagged logic returns an expiration date format matching `mm/yy` if no valid format can be identified", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "05/24"]);
});
});
});

View File

@@ -29,7 +29,6 @@ import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import {
UriMatchStrategySetting,
UriMatchStrategy,
@@ -1212,161 +1211,7 @@ export default class AutofillService implements AutofillServiceInterface {
AutofillService.hasValue(card.expMonth) &&
AutofillService.hasValue(card.expYear)
) {
let combinedExpiryFillValue = null;
const enableNewCardCombinedExpiryAutofill = await this.configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
if (enableNewCardCombinedExpiryAutofill) {
combinedExpiryFillValue = this.generateCombinedExpiryValue(card, fillFields.exp);
} else {
const fullMonth = ("0" + card.expMonth).slice(-2);
let fullYear: string = card.expYear;
let partYear: string = null;
if (fullYear.length === 2) {
partYear = fullYear;
fullYear = normalizeExpiryYearFormat(fullYear);
} else if (fullYear.length === 4) {
partYear = fullYear.substr(2, 2);
}
for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) {
if (
// mm/yyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + "/" + fullYear;
} else if (
// mm/yy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + "/" + partYear;
} else if (
// yyyy/mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + "/" + fullMonth;
} else if (
// yy/mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + "/" + fullMonth;
} else if (
// mm-yyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + "-" + fullYear;
} else if (
// mm-yy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + "-" + partYear;
} else if (
// yyyy-mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + "-" + fullMonth;
} else if (
// yy-mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + "-" + fullMonth;
} else if (
// yyyymm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + fullMonth;
} else if (
// yymm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + fullMonth;
} else if (
// mmyyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + fullYear;
} else if (
// mmyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + partYear;
}
if (combinedExpiryFillValue != null) {
break;
}
}
// If none of the previous cases applied, set as default
if (combinedExpiryFillValue == null) {
combinedExpiryFillValue = fullYear + "-" + fullMonth;
}
}
const combinedExpiryFillValue = this.generateCombinedExpiryValue(card, fillFields.exp);
this.makeScriptActionWithValue(
fillScript,

View File

@@ -49,6 +49,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
private mutationObserver: MutationObserver;
private mutationsQueue: MutationRecord[][] = [];
private updateAfterMutationIdleCallback: NodeJS.Timeout | number;
private ownedExperienceTagNames: string[] = [];
private readonly updateAfterMutationTimeout = 1000;
private readonly formFieldQueryString;
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
@@ -85,6 +86,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
* @public
*/
async getPageDetails(): Promise<AutofillPageDetails> {
// Set up listeners on top-layer candidates that predate Mutation Observer setup
this.setupInitialTopLayerListeners();
if (!this.mutationObserver) {
this.setupMutationObserver();
}
@@ -919,6 +923,18 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
}
private setupInitialTopLayerListeners = () => {
const unownedTopLayerItems = this.autofillOverlayContentService?.getUnownedTopLayerItems(true);
if (unownedTopLayerItems?.length) {
for (const unownedElement of unownedTopLayerItems) {
if (this.shouldListenToTopLayerCandidate(unownedElement)) {
this.setupTopLayerCandidateListener(unownedElement);
}
}
}
};
/**
* Sets up a mutation observer on the body of the document. Observes changes to
* DOM elements to ensure we have an updated set of autofill field data.
@@ -1044,6 +1060,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
* @private
*/
private processMutationRecord(mutation: MutationRecord) {
this.handleTopLayerChanges(mutation);
if (
mutation.type === "childList" &&
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
@@ -1058,6 +1076,64 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
}
}
private setupTopLayerCandidateListener = (element: Element) => {
const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || [];
this.ownedExperienceTagNames = ownedTags;
if (!ownedTags.includes(element.tagName)) {
element.addEventListener("toggle", (event: ToggleEvent) => {
if (event.newState === "open") {
// Add a slight delay (but faster than a user's reaction), to ensure the layer
// positioning happens after any triggered toggle has completed.
setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100);
}
});
}
};
private isPopoverAttribute = (attr: string | null) => {
const popoverAttributes = new Set(["popover", "popovertarget", "popovertargetaction"]);
return attr && popoverAttributes.has(attr.toLowerCase());
};
private shouldListenToTopLayerCandidate = (element: Element) => {
return (
!this.ownedExperienceTagNames.includes(element.tagName) &&
(element.tagName === "DIALOG" ||
Array.from(element.attributes || []).some((attribute) =>
this.isPopoverAttribute(attribute.name),
))
);
};
/**
* Checks if a mutation record is related features that utilize the top layer.
* If so, it then calls `setupTopLayerElementListener` for future event
* listening on the relevant element.
*
* @param mutation - The MutationRecord to check
*/
private handleTopLayerChanges = (mutation: MutationRecord) => {
// Check attribute mutations
if (mutation.type === "attributes" && this.isPopoverAttribute(mutation.attributeName)) {
this.setupTopLayerCandidateListener(mutation.target as Element);
}
// Check added nodes for dialog or popover attributes
if (mutation.type === "childList" && mutation.addedNodes?.length > 0) {
for (const node of mutation.addedNodes) {
const mutationElement = node as Element;
if (this.shouldListenToTopLayerCandidate(mutationElement)) {
this.setupTopLayerCandidateListener(mutationElement);
}
}
}
return;
};
/**
* Checks if the passed nodes either contain or are autofill elements.
*

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.8.1",
"version": "2025.8.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2025.8.1",
"version": "2025.8.2",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -73,8 +73,29 @@ export class LocalBackedSessionStorageService
const value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
this.cache[key] = value;
return value as T;
if (this.cache[key] === undefined && value !== undefined) {
// Cache is still empty and we just got a value from local/session storage, cache it.
this.cache[key] = value;
return value as T;
} else if (this.cache[key] === undefined && value === undefined) {
// Cache is still empty and we got nothing from local/session storage, no need to modify cache.
return value as T;
} else if (this.cache[key] !== undefined && value !== undefined) {
// Conflict, somebody wrote to the cache while we were reading from storage
// but we also got a value from storage. We assume the cache is more up to date
// and use that value.
this.logService.warning(
`Conflict while reading from local session storage, both cache and storage have values. Key: ${key}. Using cached value.`,
);
return this.cache[key] as T;
} else if (this.cache[key] !== undefined && value === undefined) {
// Cache was filled after the local/session storage read completed. We got null
// from the storage read, but we have a value from the cache, use that.
this.logService.warning(
`Conflict while reading from local session storage, cache has value but storage does not. Key: ${key}. Using cached value.`,
);
return this.cache[key] as T;
}
}
async has(key: string): Promise<boolean> {

View File

@@ -45,7 +45,10 @@
<bit-card>
<bit-form-control>
<input bitCheckbox formControlName="enableFavicon" type="checkbox" />
<bit-label>{{ "enableFavicon" | i18n }}</bit-label>
<bit-label>
{{ "showIconsChangePasswordUrls" | i18n }}
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</bit-label>
</bit-form-control>
<bit-form-control>
<input bitCheckbox formControlName="showQuickCopyActions" type="checkbox" />

View File

@@ -23,6 +23,7 @@ import {
Option,
SelectModule,
} from "@bitwarden/components";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { PopupWidthOption } from "../../../platform/browser/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
@@ -46,6 +47,7 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto
ReactiveFormsModule,
CheckboxModule,
BadgeModule,
PermitCipherDetailsPopoverComponent,
],
})
export class AppearanceV2Component implements OnInit {

View File

@@ -333,7 +333,7 @@ export class LoginCommand {
}
// Run full sync before handling success response or password reset flows (to get Master Password Policies)
await this.syncService.fullSync(true);
await this.syncService.fullSync(true, { skipTokenRefresh: true });
// Handle updating passwords if NOT using an API Key for authentication
if (clientId == null && clientSecret == null) {

View File

@@ -3,3 +3,8 @@ dist-safari/
*.env
PlugIns/safari.appex/
xcuserdata/
# Fastlane
fastlane/report.xml
fastlane/README.md
fastlane/release_notes/

View File

@@ -0,0 +1,174 @@
default_platform(:mac)
# Static configuration for the Mac desktop app
require 'json'
require 'base64'
APP_CONFIG = {
app_identifier: "com.bitwarden.desktop",
release_notes_path: "fastlane/release_notes",
locales: ["ca", "zh-Hans", "zh-Hant", "da", "nl-NL", "en-US", "fi", "fr-FR", "de-DE", "id", "it", "ja", "ko", "no", "pt-PT", "pt-BR", "ru", "es-ES", "es-MX", "sv", "tr", "vi", "en-GB", "th"]
}
platform :mac do
desc "Prepare release notes from changelog"
lane :prepare_release_notes do |options|
changelog = options[:changelog] || "Bug fixes and improvements"
# Split on periods and format with bullet points
# Try different formatting approaches for App Store Connect
formatted_changelog = changelog
.split('.')
.map(&:strip)
.reject(&:empty?)
.map { |item| "• #{item}" }
.join("\n")
UI.message("Original changelog: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}")
UI.message("Formatted changelog: #{formatted_changelog[0,100]}#{formatted_changelog.length > 100 ? '...' : ''}")
# Create release notes directories and files for all locales
APP_CONFIG[:locales].each do |locale|
dir = "release_notes/#{locale}"
FileUtils.mkdir_p(dir)
File.write("#{dir}/release_notes.txt", formatted_changelog)
UI.message("Creating release notes for #{locale}")
end
# Create release notes hash for deliver
notes = APP_CONFIG[:locales].each_with_object({}) do |locale, hash|
file_path = "release_notes/#{locale}/release_notes.txt"
if File.exist?(file_path)
hash[locale] = File.read(file_path)
else
UI.important("No release notes found for #{locale} at #{file_path}, skipping.")
end
end
UI.success("✅ Prepared release notes for #{APP_CONFIG[:locales].count} locales")
notes
end
desc "Display configuration information"
lane :show_config do |options|
build_number = (options[:build_number] || ENV["BUILD_NUMBER"]).to_s.strip
app_version = (options[:app_version] || ENV["APP_VERSION"]).to_s.strip
UI.message("📦 App ID: #{APP_CONFIG[:app_identifier]}")
UI.message("🏷️ Version: #{app_version.empty? ? '(not set)' : app_version}")
UI.message("🔢 Build Number: #{build_number}")
UI.message("🌍 Locales: #{APP_CONFIG[:locales].count}")
end
desc "Publish desktop to the Mac App Store"
lane :publish do |options|
build_number = (options[:build_number] || ENV["BUILD_NUMBER"]).to_s.strip
app_version = (options[:app_version] || ENV["APP_VERSION"]).to_s.strip
changelog = options[:changelog] || "Bug fixes and improvements"
is_dry_run = options[:dry_run] == "true" || options[:dry_run] == true
if is_dry_run
UI.header("🧪 DRY RUN: Testing Bitwarden Desktop App Store submission")
else
UI.header("🚀 Publishing Bitwarden Desktop to Mac App Store")
end
# Show configuration info
show_config(build_number: build_number, app_version: app_version)
# Validate app_version
UI.user_error!("❌ APP_VERSION is required") if app_version.nil? || app_version.empty?
# Validate build_number
UI.user_error!("❌ BUILD_NUMBER is required") if build_number.nil? || build_number.empty?
# Prepare release notes for all locales
notes = prepare_release_notes(changelog: changelog)
if is_dry_run
UI.important("🧪 DRY RUN MODE - Skipping actual App Store Connect submission")
UI.message("✅ Validation passed")
UI.message("✅ Release notes prepared for #{APP_CONFIG[:locales].count} locales")
UI.message("✅ Release notes: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}")
UI.success("🎯 DRY RUN COMPLETE - Everything looks ready for production!")
next # Use 'next' instead of 'return' in fastlane lanes
end
# Set up App Store Connect API
app_store_connect_api_key(
key_id: "6TV9MKN3GP",
issuer_id: ENV["APP_STORE_CONNECT_TEAM_ISSUER"],
key_content: Base64.encode64(ENV["APP_STORE_CONNECT_AUTH_KEY"]),
is_key_content_base64: true
)
UI.message("📝 Using release notes for #{notes.keys.count} locales")
UI.message("🎯 Publishing version #{app_version} with build #{build_number}")
# Upload to App Store Connect
deliver(
platform: "osx",
app_identifier: APP_CONFIG[:app_identifier],
app_version: app_version,
build_number: build_number,
metadata_path: "metadata",
skip_binary_upload: true,
skip_screenshots: true,
skip_metadata: false, # Enable metadata upload to include release notes
release_notes: notes,
edit_live: false,
submit_for_review: true, # if this is false, the build number does not attach to the draft
phased_release: true, # Enable 7-day phased rollout
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
automatic_release: true,
force: true
)
# Verify submission in App Store Connect (skip in dry run mode)
unless is_dry_run
UI.message("⏳ Waiting 60 seconds for App Store Connect to process submission...")
sleep(60)
UI.message("🔍 Verifying submission in App Store Connect...")
# Find the app
app = Spaceship::ConnectAPI::App.find(APP_CONFIG[:app_identifier])
UI.user_error!("❌ App not found in App Store Connect") if app.nil?
# Find the version we just submitted
versions = app.get_app_store_versions
target_version = nil
versions.each do |v|
if v.version_string == app_version
target_version = v
break
end
end
UI.user_error!("❌ Version #{app_version} not found in App Store Connect after submission") if target_version.nil?
UI.success("✅ Version #{app_version} found in App Store Connect")
UI.message("📊 Current status: #{target_version.app_store_state}")
# Validate build attachment
if target_version.build.nil?
UI.user_error!("❌ No build attached to version #{app_version}")
elsif target_version.build.version != build_number
UI.user_error!("❌ Wrong build attached: found #{target_version.build.version}, expected #{build_number}")
else
UI.success("✅ Build #{build_number} correctly attached to version #{app_version}")
end
# Check submission status
valid_states = ["WAITING_FOR_REVIEW", "IN_REVIEW"]
unless valid_states.include?(target_version.app_store_state)
UI.user_error!("❌ Unexpected submission state: #{target_version.app_store_state}. Expected one of: #{valid_states.join(', ')}")
end
UI.success("🎉 Verification complete: Version #{app_version} with build #{build_number} successfully submitted!")
else
UI.success("🧪 DRY RUN: Skipping App Store Connect verification")
end
end
end

View File

@@ -1,7 +1,7 @@
{
"name": "@bitwarden/desktop",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.8.1",
"version": "2025.8.2",
"keywords": [
"bitwarden",
"password",

View File

@@ -162,14 +162,15 @@
<input
id="enableFavicons"
type="checkbox"
aria-describedby="enableFaviconsHelp"
formControlName="enableFavicons"
(change)="saveFavicons()"
/>
{{ "enableFavicon" | i18n }}
{{ "showIconsChangePasswordUrls" | i18n }}
</label>
<div class="tw-inline-block tw-ml-2">
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</div>
</div>
<small id="enableFaviconsHelp" class="help-block">{{ "faviconDesc" | i18n }}</small>
</div>
</ng-container>
</div>

View File

@@ -52,6 +52,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
@@ -81,6 +82,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man
SelectModule,
TypographyModule,
VaultTimeoutInputComponent,
PermitCipherDetailsPopoverComponent,
],
})
export class SettingsComponent implements OnInit, OnDestroy {

View File

@@ -31,7 +31,6 @@ import { SharedModule } from "./shared/shared.module";
@NgModule({
imports: [
BrowserAnimationsModule,
SharedModule,
AppRoutingModule,
VaultFilterModule,

View File

@@ -1305,11 +1305,8 @@
"message": "Automatically clear copied values from your clipboard.",
"description": "Clipboard is the operating system thing where you copy/paste data to on your device."
},
"enableFavicon": {
"message": "Show website icons"
},
"faviconDesc": {
"message": "Show a recognizable image next to each login."
"showIconsChangePasswordUrls": {
"message": "Show website icons and retrieve change password URLs"
},
"enableMinToTray": {
"message": "Minimize to tray icon"
@@ -3928,6 +3925,12 @@
"description": "Two part message",
"example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent"
},
"aboutThisSetting": {
"message": "About this setting"
},
"permitCipherDetailsDescription": {
"message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service."
},
"assignToCollections": {
"message": "Assign to collections"
},

View File

@@ -1,12 +1,12 @@
{
"name": "@bitwarden/desktop",
"version": "2025.8.1",
"version": "2025.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@bitwarden/desktop",
"version": "2025.8.1",
"version": "2025.8.2",
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/desktop-napi": "file:../desktop_native/napi"

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/desktop",
"productName": "Bitwarden",
"description": "A secure and free password manager for all of your devices.",
"version": "2025.8.1",
"version": "2025.8.2",
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"license": "GPL-3.0",

View File

@@ -1,6 +1,5 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule } from "../../../shared/loose-components.module";
import { SharedModule } from "../../../shared/shared.module";
import { OrganizationBadgeModule } from "../../../vault/individual-vault/organization-badge/organization-badge.module";
import { ViewComponent } from "../../../vault/individual-vault/view.component";
@@ -15,7 +14,6 @@ import { VaultComponent } from "./vault.component";
imports: [
VaultRoutingModule,
SharedModule,
LooseComponentsModule,
GroupBadgeModule,
CollectionNameBadgeComponent,
OrganizationBadgeModule,

View File

@@ -6,7 +6,7 @@ import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components";
import { LooseComponentsModule } from "../../../shared";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedOrganizationModule } from "../shared";
import { BulkConfirmDialogComponent } from "./components/bulk/bulk-confirm-dialog.component";
@@ -22,10 +22,10 @@ import { MembersComponent } from "./members.component";
@NgModule({
imports: [
SharedOrganizationModule,
LooseComponentsModule,
MembersRoutingModule,
UserDialogModule,
PasswordCalloutComponent,
HeaderModule,
ScrollingModule,
PasswordStrengthV2Component,
ScrollLayoutDirective,

View File

@@ -4,7 +4,7 @@ import { NgModule } from "@angular/core";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
import { LooseComponentsModule } from "../../shared";
import { HeaderModule } from "../../layouts/header/header.module";
import { CoreOrganizationModule } from "./core";
import { GroupAddEditComponent } from "./manage/group-add-edit.component";
@@ -19,7 +19,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector";
AccessSelectorModule,
CoreOrganizationModule,
OrganizationsRoutingModule,
LooseComponentsModule,
HeaderModule,
ScrollingModule,
ScrollLayoutDirective,
OrganizationWarningsModule,

View File

@@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { DisableSendPolicyComponent } from "./disable-send.component";
import { MasterPasswordPolicyComponent } from "./master-password.component";
@@ -17,7 +18,7 @@ import { SingleOrgPolicyComponent } from "./single-org.component";
import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authentication.component";
@NgModule({
imports: [SharedModule, LooseComponentsModule],
imports: [SharedModule, HeaderModule],
declarations: [
DisableSendPolicyComponent,
MasterPasswordPolicyComponent,

View File

@@ -1,19 +1,14 @@
import { NgModule } from "@angular/core";
import { ReportsSharedModule } from "../../../dirt/reports";
import { LooseComponentsModule } from "../../../shared";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { OrganizationReportingRoutingModule } from "./organization-reporting-routing.module";
import { ReportsHomeComponent } from "./reports-home.component";
@NgModule({
imports: [
SharedModule,
ReportsSharedModule,
OrganizationReportingRoutingModule,
LooseComponentsModule,
],
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule, HeaderModule],
declarations: [ReportsHomeComponent],
})
export class OrganizationReportingModule {}

View File

@@ -2,8 +2,11 @@ import { NgModule } from "@angular/core";
import { ItemModule } from "@bitwarden/components";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { DangerZoneComponent } from "../../../auth/settings/account/danger-zone.component";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component";
import { PoliciesModule } from "../../organizations/policies";
import { AccountComponent } from "./account.component";
@@ -13,10 +16,12 @@ import { TwoFactorSetupComponent } from "./two-factor-setup.component";
@NgModule({
imports: [
SharedModule,
LooseComponentsModule,
PoliciesModule,
OrganizationSettingsRoutingModule,
AccountFingerprintComponent,
DangerZoneComponent,
HeaderModule,
PremiumBadgeComponent,
ItemModule,
],
declarations: [AccountComponent, TwoFactorSetupComponent],

View File

@@ -421,7 +421,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
collectionView.users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView);
collectionView.defaultUserCollectionEmail = this.collection.defaultUserCollectionEmail;
collectionView.defaultUserCollectionEmail = this.collection?.defaultUserCollectionEmail;
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -33,8 +33,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogRef, DialogService, ItemModule } from "@bitwarden/components";
import { LooseComponentsModule } from "../../../shared/loose-components.module";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared/shared.module";
import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component";
import { TwoFactorRecoveryComponent } from "./two-factor-recovery.component";
import { TwoFactorSetupAuthenticatorComponent } from "./two-factor-setup-authenticator.component";
@@ -47,7 +48,7 @@ import { TwoFactorVerifyComponent } from "./two-factor-verify.component";
@Component({
selector: "app-two-factor-setup",
templateUrl: "two-factor-setup.component.html",
imports: [ItemModule, LooseComponentsModule, SharedModule],
imports: [ItemModule, HeaderModule, PremiumBadgeComponent, SharedModule],
})
export class TwoFactorSetupComponent implements OnInit, OnDestroy {
organizationId: string;

View File

@@ -4,7 +4,7 @@ import { NgModule } from "@angular/core";
// eslint-disable-next-line no-restricted-imports
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
import { LooseComponentsModule } from "../../shared";
import { HeaderModule } from "../../layouts/header/header.module";
import { BillingSharedModule } from "../shared";
import { AdjustSubscription } from "./adjust-subscription.component";
@@ -29,7 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
UserVerificationModule,
BillingSharedModule,
OrganizationPlansComponent,
LooseComponentsModule,
HeaderModule,
BannerModule,
],
declarations: [

View File

@@ -3,7 +3,9 @@ import { NgModule } from "@angular/core";
import { AuthModule } from "./auth";
import { LoginModule } from "./auth/login/login.module";
import { TrialInitiationModule } from "./billing/trial-initiation/trial-initiation.module";
import { LooseComponentsModule, SharedModule } from "./shared";
import { HeaderModule } from "./layouts/header/header.module";
import { SharedModule } from "./shared";
import { LooseComponentsModule } from "./shared/loose-components.module";
import { AccessComponent } from "./tools/send/send-access/access.component";
import { OrganizationBadgeModule } from "./vault/individual-vault/organization-badge/organization-badge.module";
import { VaultFilterModule } from "./vault/individual-vault/vault-filter/vault-filter.module";
@@ -15,6 +17,7 @@ import "./shared/locales";
imports: [
SharedModule,
LooseComponentsModule,
HeaderModule,
TrialInitiationModule,
VaultFilterModule,
OrganizationBadgeModule,
@@ -24,7 +27,7 @@ import "./shared/locales";
],
exports: [
SharedModule,
LooseComponentsModule,
HeaderModule,
TrialInitiationModule,
VaultFilterModule,
OrganizationBadgeModule,

View File

@@ -67,23 +67,17 @@
</bit-select>
<bit-hint>{{ "languageDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
<bit-label
>{{ "enableFavicon" | i18n }}
<a
bitLink
href="https://bitwarden.com/help/website-icons/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutWebsiteIcons' | i18n }}"
slot="end"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-hint>{{ "faviconDesc" | i18n }}</bit-hint>
</bit-form-control>
<div class="tw-flex tw-items-start tw-gap-1.5">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
<bit-label>
{{ "showIconsChangePasswordUrls" | i18n }}
</bit-label>
</bit-form-control>
<div class="-tw-mt-0.5">
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
</div>
</div>
<bit-form-field>
<bit-label>{{ "theme" | i18n }}</bit-label>
<bit-select formControlName="theme" id="theme">

View File

@@ -34,6 +34,7 @@ import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { DialogService } from "@bitwarden/components";
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { HeaderModule } from "../layouts/header/header.module";
import { SharedModule } from "../shared";
@@ -41,7 +42,12 @@ import { SharedModule } from "../shared";
@Component({
selector: "app-preferences",
templateUrl: "preferences.component.html",
imports: [SharedModule, HeaderModule, VaultTimeoutInputComponent],
imports: [
SharedModule,
HeaderModule,
VaultTimeoutInputComponent,
PermitCipherDetailsPopoverComponent,
],
})
export class PreferencesComponent implements OnInit, OnDestroy {
// For use in template

View File

@@ -1,2 +1 @@
export * from "./shared.module";
export * from "./loose-components.module";

View File

@@ -1,18 +1,7 @@
import { NgModule } from "@angular/core";
import {
PasswordCalloutComponent,
UserVerificationFormInputComponent,
VaultTimeoutInputComponent,
} from "@bitwarden/auth/angular";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";
import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component";
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component";
import { UserVerificationModule } from "../auth/shared/components/user-verification";
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component";
@@ -30,33 +19,15 @@ import { UnsecuredWebsitesReportComponent as OrgUnsecuredWebsitesReportComponent
import { WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent } from "../dirt/reports/pages/organizations/weak-passwords-report.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { HeaderModule } from "../layouts/header/header.module";
import { PremiumBadgeComponent } from "../vault/components/premium-badge.component";
import { OrganizationBadgeModule } from "../vault/individual-vault/organization-badge/organization-badge.module";
import { PipesModule } from "../vault/individual-vault/pipes/pipes.module";
import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component";
import { SharedModule } from "./shared.module";
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
// If you are building new functionality, please create or extend a feature module instead.
@NgModule({
imports: [
SharedModule,
UserVerificationModule,
AccountFingerprintComponent,
OrganizationBadgeModule,
PipesModule,
PasswordCalloutComponent,
UserVerificationFormInputComponent,
DangerZoneComponent,
LayoutComponent,
NavigationModule,
HeaderModule,
OrganizationLayoutComponent,
VerifyRecoverDeleteOrgComponent,
VaultTimeoutInputComponent,
PremiumBadgeComponent,
],
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
declarations: [
OrgExposedPasswordsReportComponent,
OrgInactiveTwoFactorReportComponent,
@@ -73,25 +44,12 @@ import { SharedModule } from "./shared.module";
VerifyRecoverDeleteComponent,
],
exports: [
UserVerificationModule,
PremiumBadgeComponent,
OrganizationLayoutComponent,
OrgExposedPasswordsReportComponent,
OrgInactiveTwoFactorReportComponent,
OrgReusedPasswordsReportComponent,
OrgUnsecuredWebsitesReportComponent,
OrgWeakPasswordsReportComponent,
PremiumBadgeComponent,
RecoverDeleteComponent,
RecoverTwoFactorComponent,
RemovePasswordComponent,
SponsoredFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
VerifyEmailTokenComponent,
VerifyRecoverDeleteComponent,
HeaderModule,
DangerZoneComponent,
],
})
export class LooseComponentsModule {}

View File

@@ -14,13 +14,14 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ImportCollectionServiceAbstraction } from "@bitwarden/importer-core";
import { ImportComponent } from "@bitwarden/importer-ui";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
import { ImportCollectionAdminService } from "./import-collection-admin.service";
@Component({
templateUrl: "org-import.component.html",
imports: [SharedModule, ImportComponent, LooseComponentsModule],
imports: [SharedModule, ImportComponent, HeaderModule],
providers: [
{
provide: ImportCollectionServiceAbstraction,

View File

@@ -5,11 +5,12 @@ import { ActivatedRoute } from "@angular/router";
import { ExportComponent } from "@bitwarden/vault-export-ui";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared";
@Component({
templateUrl: "org-vault-export.component.html",
imports: [SharedModule, ExportComponent, LooseComponentsModule],
imports: [SharedModule, ExportComponent, HeaderModule],
})
export class OrganizationVaultExportComponent implements OnInit {
protected routeOrgId: string = null;

View File

@@ -3,7 +3,7 @@ import { NgModule } from "@angular/core";
import { CollectionNameBadgeComponent } from "../../admin-console/organizations/collections";
import { GroupBadgeModule } from "../../admin-console/organizations/collections/group-badge/group-badge.module";
import { CollectionDialogComponent } from "../../admin-console/organizations/shared/components/collection-dialog";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { SharedModule } from "../../shared";
import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module";
import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module";
@@ -20,7 +20,6 @@ import { ViewComponent } from "./view.component";
CollectionNameBadgeComponent,
PipesModule,
SharedModule,
LooseComponentsModule,
BulkDialogsModule,
CollectionDialogComponent,
VaultComponent,

View File

@@ -176,8 +176,8 @@
"totalApplications": {
"message": "Total applications"
},
"unmarkAsCriticalApp": {
"message": "Unmark as critical app"
"unmarkAsCritical": {
"message": "Unmark as critical"
},
"criticalApplicationSuccessfullyUnmarked": {
"message": "Critical application successfully unmarked"
@@ -867,6 +867,15 @@
"copyLicenseNumber": {
"message": "Copy license number"
},
"copyPrivateKey": {
"message": "Copy private key"
},
"copyPublicKey": {
"message": "Copy public key"
},
"copyFingerprint": {
"message": "Copy fingerprint"
},
"copyName": {
"message": "Copy name"
},
@@ -2103,11 +2112,8 @@
"languageDesc": {
"message": "Change the language used by the web vault."
},
"enableFavicon": {
"message": "Show website icons"
},
"faviconDesc": {
"message": "Show a recognizable image next to each login."
"showIconsChangePasswordUrls": {
"message": "Show website icons and retrieve change password URLs"
},
"default": {
"message": "Default"
@@ -10986,6 +10992,12 @@
"message": "Billing address required to add credit.",
"description": "Error message shown when trying to add credit to a trialing organization without a billing address."
},
"aboutThisSetting": {
"message": "About this setting"
},
"permitCipherDetailsDescription": {
"message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service."
},
"billingAddress": {
"message": "Billing address"
},

View File

@@ -21,7 +21,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { TableDataSource, NoItemsModule, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
@Component({
@@ -43,7 +43,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
],
}),
] satisfies SafeProvider[],
imports: [SharedModule, NoItemsModule, LooseComponentsModule],
imports: [SharedModule, NoItemsModule, HeaderModule],
})
export class DeviceApprovalsComponent implements OnInit, OnDestroy {
tableDataSource = new TableDataSource<PendingAuthRequestWithFingerprintView>();

View File

@@ -1,6 +1,6 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule } from "@bitwarden/web-vault/app/shared";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module";
import { SsoComponent } from "../../auth/sso/sso.component";
@@ -11,7 +11,7 @@ import { ScimComponent } from "./manage/scim.component";
import { OrganizationsRoutingModule } from "./organizations-routing.module";
@NgModule({
imports: [SharedModule, OrganizationsRoutingModule, LooseComponentsModule],
imports: [SharedModule, OrganizationsRoutingModule, HeaderModule],
declarations: [
SsoComponent,
ScimComponent,

View File

@@ -88,8 +88,8 @@
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="unmarkAsCriticalApp(row.applicationName)">
{{ "unmarkAsCriticalApp" | i18n }}
<button type="button" bitMenuItem (click)="unmarkAsCritical(row.applicationName)">
{{ "unmarkAsCritical" | i18n }}
</button>
</bit-menu>
</td>

View File

@@ -19,6 +19,6 @@ export class AppTableRowScrollableComponent {
@Input() selectedUrls: Set<string> = new Set<string>();
@Input() isDrawerIsOpenForThisRecord!: (applicationName: string) => boolean;
@Input() showAppAtRiskMembers!: (applicationName: string) => void;
@Input() unmarkAsCriticalApp!: (applicationName: string) => void;
@Input() unmarkAsCritical!: (applicationName: string) => void;
@Input() checkboxChange!: (applicationName: string, $event: Event) => void;
}

View File

@@ -83,6 +83,6 @@
[showRowMenuForCriticalApps]="true"
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
[showAppAtRiskMembers]="showAppAtRiskMembers"
[unmarkAsCriticalApp]="unmarkAsCriticalApp"
[unmarkAsCritical]="unmarkAsCritical"
></app-table-row-scrollable>
</div>

View File

@@ -106,7 +106,7 @@ export class CriticalApplicationsComponent implements OnInit {
);
};
unmarkAsCriticalApp = async (hostname: string) => {
unmarkAsCritical = async (hostname: string) => {
try {
await this.criticalAppsService.dropCriticalApp(
this.organizationId as OrganizationId,

View File

@@ -1,135 +0,0 @@
import { Component } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { RouterService } from "@bitwarden/web-vault/app/core";
import { ProjectView } from "../../models/view/project.view";
import { ProjectService } from "../project.service";
import { projectAccessGuard } from "./project-access.guard";
@Component({
template: "",
standalone: false,
})
export class GuardedRouteTestComponent {}
@Component({
template: "",
standalone: false,
})
export class RedirectTestComponent {}
describe("Project Redirect Guard", () => {
let organizationService: MockProxy<OrganizationService>;
let routerService: MockProxy<RouterService>;
let projectServiceMock: MockProxy<ProjectService>;
let i18nServiceMock: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let router: Router;
let accountService: FakeAccountService;
const userId = Utils.newGuid() as UserId;
const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization;
const projectView = {
id: "123",
organizationId: "123",
name: "project-name",
creationDate: Date.now.toString(),
revisionDate: Date.now.toString(),
read: true,
write: true,
} as ProjectView;
beforeEach(async () => {
organizationService = mock<OrganizationService>();
routerService = mock<RouterService>();
projectServiceMock = mock<ProjectService>();
i18nServiceMock = mock<I18nService>();
toastService = mock<ToastService>();
accountService = mockAccountServiceWith(userId);
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{
path: "sm/:organizationId/projects/:projectId",
component: GuardedRouteTestComponent,
canActivate: [projectAccessGuard],
},
{
path: "sm",
component: RedirectTestComponent,
},
{
path: "sm/:organizationId/projects",
component: RedirectTestComponent,
},
]),
],
providers: [
{ provide: OrganizationService, useValue: organizationService },
{ provide: AccountService, useValue: accountService },
{ provide: RouterService, useValue: routerService },
{ provide: ProjectService, useValue: projectServiceMock },
{ provide: I18nService, useValue: i18nServiceMock },
{ provide: ToastService, useValue: toastService },
],
});
router = TestBed.inject(Router);
});
it("redirects to sm/{orgId}/projects/{projectId} if project exists", async () => {
// Arrange
organizationService.organizations$.mockReturnValue(of([smOrg1]));
projectServiceMock.getByProjectId.mockReturnValue(Promise.resolve(projectView));
// Act
await router.navigateByUrl("sm/123/projects/123");
// Assert
expect(router.url).toBe("/sm/123/projects/123");
});
it("redirects to sm/projects if project does not exist", async () => {
// Arrange
organizationService.organizations$.mockReturnValue(of([smOrg1]));
// Act
await router.navigateByUrl("sm/123/projects/124");
// Assert
expect(router.url).toBe("/sm/123/projects");
});
it("redirects to sm/123/projects if exception occurs while looking for Project", async () => {
// Arrange
jest.spyOn(projectServiceMock, "getByProjectId").mockImplementation(() => {
throw new Error("Test error");
});
jest.spyOn(i18nServiceMock, "t").mockReturnValue("Project not found");
// Act
await router.navigateByUrl("sm/123/projects/123");
// Assert
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: null,
message: "Project not found",
});
expect(router.url).toBe("/sm/123/projects");
});
});

View File

@@ -1,33 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { ProjectService } from "../project.service";
/**
* Redirects to projects list if the user doesn't have access to project.
*/
export const projectAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
const projectService = inject(ProjectService);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
try {
const project = await projectService.getByProjectId(route.params.projectId);
if (project) {
return true;
}
} catch {
toastService.showToast({
variant: "error",
title: null,
message: i18nService.t("notFound", i18nService.t("project")),
});
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]);
}
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]);
};

View File

@@ -1,7 +1,7 @@
<ng-container *ngIf="{ project: project$ | async, secrets: secrets$ | async } as projectSecrets">
<ng-container *ngIf="projectSecrets?.secrets && projectSecrets?.project; else spinner">
<ng-container *ngIf="{ secrets: secrets$ | async } as projectSecrets">
<ng-container *ngIf="projectSecrets?.secrets && this.projectExists(); else spinner">
<div
*ngIf="projectSecrets.secrets?.length > 0 && projectSecrets.project?.write"
*ngIf="projectSecrets.secrets?.length > 0 && this.writeAccess()"
class="tw-float-right tw-mt-3 tw-items-center"
>
<button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
@@ -10,7 +10,7 @@
</button>
</div>
<sm-secrets-list
*ngIf="projectSecrets.secrets?.length > 0 || projectSecrets.project?.write; else contactAdmin"
*ngIf="projectSecrets.secrets?.length > 0 || this.writeAccess(); else contactAdmin"
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"

View File

@@ -1,16 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
combineLatestWith,
filter,
firstValueFrom,
Observable,
startWith,
switchMap,
} from "rxjs";
import { Component, computed, inject, OnInit, Signal } from "@angular/core";
import { ActivatedRoute, ROUTER_OUTLET_DATA } from "@angular/router";
import { combineLatestWith, firstValueFrom, Observable, startWith, switchMap } from "rxjs";
import {
getOrganizationById,
@@ -40,7 +32,6 @@ import {
} from "../../secrets/dialog/secret-view-dialog.component";
import { SecretService } from "../../secrets/secret.service";
import { SecretsListComponent } from "../../shared/secrets-list.component";
import { ProjectService } from "../project.service";
@Component({
selector: "sm-project-secrets",
@@ -54,10 +45,12 @@ export class ProjectSecretsComponent implements OnInit {
private projectId: string;
protected project$: Observable<ProjectView>;
private organizationEnabled: boolean;
protected project = inject(ROUTER_OUTLET_DATA) as Signal<ProjectView>;
readonly writeAccess = computed(() => this.project().write);
readonly projectExists = computed(() => !!this.project());
constructor(
private route: ActivatedRoute,
private projectService: ProjectService,
private secretService: SecretService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
@@ -68,21 +61,9 @@ export class ProjectSecretsComponent implements OnInit {
) {}
ngOnInit() {
// Refresh list if project is edited
const currentProjectEdited = this.projectService.project$.pipe(
filter((p) => p?.id === this.projectId),
startWith(null),
);
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
switchMap(([params, _]) => {
return this.projectService.getByProjectId(params.projectId);
}),
);
this.secrets$ = this.secretService.secret$.pipe(
startWith(null),
combineLatestWith(this.route.params, currentProjectEdited),
combineLatestWith(this.route.params),
switchMap(async ([_, params]) => {
this.organizationId = params.organizationId;
this.projectId = params.projectId;

View File

@@ -36,4 +36,4 @@
{{ "editProject" | i18n }}
</button>
</app-header>
<router-outlet></router-outlet>
<router-outlet [routerOutletData]="this.project$"></router-outlet>

View File

@@ -1,7 +1,6 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { projectAccessGuard } from "./guards/project-access.guard";
import { ProjectPeopleComponent } from "./project/project-people.component";
import { ProjectSecretsComponent } from "./project/project-secrets.component";
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
@@ -16,7 +15,6 @@ const routes: Routes = [
{
path: ":projectId",
component: ProjectComponent,
canActivate: [projectAccessGuard],
children: [
{
path: "",

View File

@@ -26,24 +26,39 @@ module.exports = {
"<rootDir>/libs/admin-console/jest.config.js",
"<rootDir>/libs/angular/jest.config.js",
"<rootDir>/libs/assets/jest.config.js",
"<rootDir>/libs/auth/jest.config.js",
"<rootDir>/libs/billing/jest.config.js",
"<rootDir>/libs/client-type/jest.config.js",
"<rootDir>/libs/common/jest.config.js",
"<rootDir>/libs/components/jest.config.js",
"<rootDir>/libs/core-test-utils/jest.config.js",
"<rootDir>/libs/dirt/card/jest.config.js",
"<rootDir>/libs/eslint/jest.config.js",
"<rootDir>/libs/guid/jest.config.js",
"<rootDir>/libs/importer/jest.config.js",
"<rootDir>/libs/key-management/jest.config.js",
"<rootDir>/libs/key-management-ui/jest.config.js",
"<rootDir>/libs/logging/jest.config.js",
"<rootDir>/libs/messaging-internal/jest.config.js",
"<rootDir>/libs/messaging/jest.config.js",
"<rootDir>/libs/node/jest.config.js",
"<rootDir>/libs/platform/jest.config.js",
"<rootDir>/libs/serialization/jest.config.js",
"<rootDir>/libs/state-test-utils/jest.config.js",
"<rootDir>/libs/state/jest.config.js",
"<rootDir>/libs/storage-core/jest.config.js",
"<rootDir>/libs/storage-test-utils/jest.config.js",
"<rootDir>/libs/tools/export/vault-export/vault-export-core/jest.config.js",
"<rootDir>/libs/tools/export/vault-export/vault-export-ui/jest.config.js",
"<rootDir>/libs/tools/generator/core/jest.config.js",
"<rootDir>/libs/tools/generator/components/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/history/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/legacy/jest.config.js",
"<rootDir>/libs/tools/generator/extensions/navigation/jest.config.js",
"<rootDir>/libs/tools/send/send-ui/jest.config.js",
"<rootDir>/libs/importer/jest.config.js",
"<rootDir>/libs/platform/jest.config.js",
"<rootDir>/libs/node/jest.config.js",
"<rootDir>/libs/user-core/jest.config.js",
"<rootDir>/libs/vault/jest.config.js",
"<rootDir>/libs/key-management/jest.config.js",
"<rootDir>/libs/key-management-ui/jest.config.js",
],
// Workaround for a memory leak that crashes tests in CI:

View File

@@ -74,13 +74,6 @@ export const authGuard: CanActivateFn = async (
return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } });
}
if (
!routerState.url.includes("remove-password") &&
(await firstValueFrom(keyConnectorService.convertAccountRequired$))
) {
return router.createUrlTree(["/remove-password"]);
}
// Handle cases where a user needs to set a password when they don't already have one:
// - TDE org user has been given "manage account recovery" permission
// - TDE offboarding on a trusted device, where we have access to their encryption key wrap with their new password
@@ -106,5 +99,14 @@ export const authGuard: CanActivateFn = async (
return router.createUrlTree([route]);
}
// Remove password when Key Connector is enabled
if (
forceSetPasswordReason == ForceSetPasswordReason.None &&
!routerState.url.includes("remove-password") &&
(await firstValueFrom(keyConnectorService.convertAccountRequired$))
) {
return router.createUrlTree(["/remove-password"]);
}
return true;
};

View File

@@ -17,7 +17,6 @@ export enum FeatureFlag {
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
/* Autofill */
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -75,7 +74,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.CreateDefaultLocation]: FALSE,
/* Autofill */
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,

View File

@@ -70,6 +70,7 @@ describe("NotificationsService", () => {
signalRNotificationConnectionService = mock<SignalRConnectionService>();
authService = mock<AuthService>();
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
configService = mock<ConfigService>();
// For these tests, use the active-user implementation (feature flag disabled)
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "../../../models/response/base.response";
export class ChangePasswordUriResponse extends BaseResponse {
uri: string | null;
constructor(response: any) {
super(response);
this.uri = this.getResponseProperty("uri");
}
}

View File

@@ -1138,6 +1138,7 @@ export class CipherService implements CipherServiceAbstraction {
}
async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any> {
await this.clearEncryptedCiphersState(userId);
await this.updateEncryptedCipherState(() => ciphers, userId);
}

View File

@@ -65,4 +65,9 @@
select {
appearance: none;
}
/* overriding preflight since the apps were built assuming svgs are inline */
svg {
display: inline;
}
}

View File

@@ -83,3 +83,44 @@ describe("basic-lib generator", () => {
expect(tree.exists(`libs/test/src/test.spec.ts`)).toBeTruthy();
});
});
it("should update jest.config.js with new library", async () => {
// Create a mock jest.config.js with existing libs
const existingJestConfig = `module.exports = {
projects: [
"<rootDir>/apps/browser/jest.config.js",
"<rootDir>/libs/admin-console/jest.config.js",
"<rootDir>/libs/auth/jest.config.js",
"<rootDir>/libs/vault/jest.config.js",
],
};`;
tree.write("jest.config.js", existingJestConfig);
await basicLibGenerator(tree, options);
const jestConfigContent = tree.read("jest.config.js");
expect(jestConfigContent).not.toBeNull();
const jestConfig = jestConfigContent?.toString();
// Should contain the new library in alphabetical order
expect(jestConfig).toContain('"<rootDir>/libs/test/jest.config.js",');
// Should be in the right alphabetical position (after auth, before vault)
const authIndex = jestConfig?.indexOf('"<rootDir>/libs/auth/jest.config.js"');
const testIndex = jestConfig?.indexOf('"<rootDir>/libs/test/jest.config.js"');
const vaultIndex = jestConfig?.indexOf('"<rootDir>/libs/vault/jest.config.js"');
expect(authIndex).toBeDefined();
expect(testIndex).toBeDefined();
expect(vaultIndex).toBeDefined();
expect(authIndex! < testIndex!).toBeTruthy();
expect(testIndex! < vaultIndex!).toBeTruthy();
});
it("should handle missing jest.config.js file gracefully", async () => {
const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
// Don't create jest.config.js file
await basicLibGenerator(tree, options);
expect(consoleSpy).toHaveBeenCalledWith("jest.config.js file not found at root");
consoleSpy.mockRestore();
});

View File

@@ -53,6 +53,9 @@ export async function basicLibGenerator(
// Update CODEOWNERS with the new lib
updateCodeowners(tree, options.directory, options.name, options.team);
// Update jest.config.js with the new lib
updateJestConfig(tree, options.directory, options.name);
// Format all new files with prettier
await formatFiles(tree);
@@ -124,4 +127,66 @@ function updateCodeowners(tree: Tree, directory: string, name: string, team: str
tree.write(codeownersPath, content + newLine);
}
/**
* Updates the jest.config.js file to include the new library
* This ensures the library's tests are included in CI runs
*
* @param {Tree} tree - The virtual file system tree
* @param {string} directory - Directory where the library is created
* @param {string} name - The library name
*/
function updateJestConfig(tree: Tree, directory: string, name: string) {
const jestConfigPath = "jest.config.js";
if (!tree.exists(jestConfigPath)) {
console.warn("jest.config.js file not found at root");
return;
}
const content = tree.read(jestConfigPath)?.toString() || "";
const libJestPath = `"<rootDir>/${directory}/${name}/jest.config.js",`;
// Find the libs section and insert the new library in alphabetical order
const lines = content.split("\n");
let insertIndex = -1;
let foundLibsSection = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if we're in the libs section
if (line.includes('"<rootDir>/libs/')) {
foundLibsSection = true;
// Extract the lib name for comparison
const match = line.match(/"<rootDir>libs([^"]+)/);
if (match) {
const existingLibName = match[1];
// If the new lib should come before this existing lib alphabetically
if (name < existingLibName) {
insertIndex = i;
break;
}
}
}
// If we were in libs section but hit a non-libs line, insert at end of libs
else if (foundLibsSection && !line.includes('"<rootDir>/libs/')) {
insertIndex = i;
break;
}
}
if (insertIndex === -1) {
console.warn(`Could not find appropriate location to insert ${name} in jest.config.js`);
return;
}
// Insert the new library line
lines.splice(insertIndex, 0, ` ${libJestPath}`);
// Write back the updated content
tree.write(jestConfigPath, lines.join("\n"));
}
export default basicLibGenerator;

View File

@@ -1,4 +1,4 @@
import { record } from "@bitwarden/serialization/deserialization-helpers";
import { record } from "@bitwarden/serialization";
describe("deserialization helpers", () => {
describe("record", () => {

View File

@@ -1,4 +1,6 @@
import { ClientLocations, StateDefinition } from "./state-definition";
import { ClientLocations } from "@bitwarden/storage-core";
import { StateDefinition } from "./state-definition";
import * as stateDefinitionsRecord from "./state-definitions";
describe.each(["web", "cli", "desktop", "browser"])(

View File

@@ -25,9 +25,10 @@
bitIconButton="bwi-clone"
bitSuffix
type="button"
[label]="'copyPrivateKey' | i18n"
[appCopyClick]="sshKey.privateKey"
showToast
[label]="'copyValue' | i18n"
[valueLabel]="'sshPrivateKey' | i18n"
></button>
</bit-form-field>
<bit-form-field>
@@ -43,9 +44,10 @@
bitIconButton="bwi-clone"
bitSuffix
type="button"
[label]="'copyPublicKey' | i18n"
[appCopyClick]="sshKey.publicKey"
showToast
[label]="'copyValue' | i18n"
[valueLabel]="'sshPublicKey' | i18n"
></button>
</bit-form-field>
<bit-form-field>
@@ -61,9 +63,10 @@
bitIconButton="bwi-clone"
bitSuffix
type="button"
[label]="'copyFingerprint' | i18n"
[appCopyClick]="sshKey.keyFingerprint"
showToast
[label]="'copyValue' | i18n"
[valueLabel]="'sshFingerprint' | i18n"
></button>
</bit-form-field>
</read-only-cipher-card>

View File

@@ -0,0 +1,22 @@
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
[bitPopoverTriggerFor]="permitDetailsPopover"
position="above-center"
[appA11yTitle]="'aboutThisSetting' | i18n"
bitLink
>
<i class="bwi bwi-question-circle"></i>
</button>
<bit-popover [title]="'aboutThisSetting' | i18n" #permitDetailsPopover>
<p>
{{ "permitCipherDetailsDescription" | i18n }}
</p>
<div class="tw-flex tw-gap-1.5 tw-items-center">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-flex">
{{ "learnMore" | i18n }}
</a>
<i slot="end" class="bwi bwi-external-link tw-text-primary-600" aria-hidden="true"></i>
</div>
</bit-popover>

View File

@@ -0,0 +1,19 @@
import { Component, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LinkModule, PopoverModule } from "@bitwarden/components";
@Component({
selector: "vault-permit-cipher-details-popover",
templateUrl: "./permit-cipher-details-popover.component.html",
imports: [PopoverModule, JslibModule, LinkModule],
})
export class PermitCipherDetailsPopoverComponent {
private platformUtilService = inject(PlatformUtilsService);
openLearnMore(e: Event) {
e.preventDefault();
this.platformUtilService.launchUri("https://bitwarden.com/help/website-icons/");
}
}

View File

@@ -20,6 +20,7 @@ export { openPasswordHistoryDialog } from "./components/password-history/passwor
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
export * from "./components/carousel";
export * from "./components/new-cipher-menu/new-cipher-menu.component";
export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";

View File

@@ -4,10 +4,14 @@
*/
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import {
Environment,
EnvironmentService,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
@@ -18,37 +22,30 @@ import { DefaultChangeLoginPasswordService } from "./default-change-login-passwo
describe("DefaultChangeLoginPasswordService", () => {
let service: DefaultChangeLoginPasswordService;
let mockShouldNotExistResponse: Response;
let mockWellKnownResponse: Response;
const getClientType = jest.fn(() => ClientType.Browser);
const mockApiService = mock<ApiService>();
const platformUtilsService = mock<PlatformUtilsService>({
getClientType,
});
const mockDomainSettingsService = mock<DomainSettingsService>();
const showFavicons$ = new BehaviorSubject<boolean>(true);
beforeEach(() => {
mockApiService.nativeFetch.mockClear();
mockApiService.fetch.mockClear();
mockApiService.fetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ uri: null }) } as Response),
);
// Default responses to success state
mockShouldNotExistResponse = new Response("Not Found", { status: 404 });
mockWellKnownResponse = new Response("OK", { status: 200 });
mockDomainSettingsService.showFavicons$ = showFavicons$;
mockApiService.nativeFetch.mockImplementation((request) => {
if (
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
) {
return Promise.resolve(mockShouldNotExistResponse);
}
const mockEnvironmentService = {
environment$: of({
getIconsUrl: () => "https://icons.bitwarden.com",
} as Environment),
} as EnvironmentService;
if (request.url.endsWith(".well-known/change-password")) {
return Promise.resolve(mockWellKnownResponse);
}
throw new Error("Unexpected request");
});
service = new DefaultChangeLoginPasswordService(mockApiService, platformUtilsService);
service = new DefaultChangeLoginPasswordService(
mockApiService,
mockEnvironmentService,
mockDomainSettingsService,
);
});
it("should return null for non-login ciphers", async () => {
@@ -85,7 +82,7 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBeNull();
});
it("should check the origin for a reliable status code", async () => {
it("should call the icons url endpoint", async () => {
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
@@ -95,35 +92,42 @@ describe("DefaultChangeLoginPasswordService", () => {
await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
expect(mockApiService.fetch).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
url: "https://icons.bitwarden.com/change-password-uri?uri=https%3A%2F%2Fexample.com%2F",
}),
);
});
it("should attempt to fetch the well-known change password URL", async () => {
it("should return the original URI when unable to verify the response", async () => {
mockApiService.fetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ uri: null }) } as Response),
);
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com" }],
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
await service.getChangePasswordUrl(cipher);
const url = await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/.well-known/change-password",
}),
);
expect(url).toBe("https://example.com/");
});
it("should return the well-known change password URL when successful at verifying the response", async () => {
it("should return the well known change url from the response", async () => {
mockApiService.fetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: "https://example.com/.well-known/change-password" }),
} as Response);
});
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com" }],
uris: [{ uri: "https://example.com/" }, { uri: "https://working.com/" }],
}),
} as CipherView;
@@ -132,49 +136,20 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBe("https://example.com/.well-known/change-password");
});
it("should return the original URI when unable to verify the response", async () => {
mockShouldNotExistResponse = new Response("Ok", { status: 200 });
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(url).toBe("https://example.com/");
});
it("should return the original URI when the well-known URL is not found", async () => {
mockWellKnownResponse = new Response("Not Found", { status: 404 });
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(url).toBe("https://example.com/");
});
it("should try the next URI if the first one fails", async () => {
mockApiService.nativeFetch.mockImplementation((request) => {
if (
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
) {
return Promise.resolve(mockShouldNotExistResponse);
mockApiService.fetch.mockImplementation((request) => {
if (request.url.includes("no-wellknown.com")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: null }),
} as Response);
}
if (request.url.endsWith(".well-known/change-password")) {
if (request.url.includes("working.com")) {
return Promise.resolve(mockWellKnownResponse);
}
return Promise.resolve(new Response("Not Found", { status: 404 }));
if (request.url.includes("working.com")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: "https://working.com/.well-known/change-password" }),
} as Response);
}
throw new Error("Unexpected request");
@@ -192,19 +167,19 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBe("https://working.com/.well-known/change-password");
});
it("should return the first URI when the client type is not browser", async () => {
getClientType.mockReturnValue(ClientType.Web);
it("returns the first URI when `showFavicons$` setting is disabled", async () => {
showFavicons$.next(false);
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }, { uri: "https://example-2.com/" }],
uris: [{ uri: "https://example.com/" }, { uri: "https://another.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).not.toHaveBeenCalled();
expect(url).toBe("https://example.com/");
expect(mockApiService.fetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,12 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ChangePasswordUriResponse } from "@bitwarden/common/vault/models/response/change-password-uri.response";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
@@ -12,7 +15,8 @@ import { ChangeLoginPasswordService } from "../abstractions/change-login-passwor
export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordService {
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService,
private domainSettingsService: DomainSettingsService,
) {}
/**
@@ -33,24 +37,19 @@ export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordSer
return null;
}
// CSP policies on the web and desktop restrict the application from making
// cross-origin requests, breaking the below .well-known URL checks.
// For those platforms, this will short circuit and return the first URL.
// PM-21024 will build a solution for the server side to handle this.
if (this.platformUtilsService.getClientType() !== "browser") {
const enableFaviconChangePassword = await firstValueFrom(
this.domainSettingsService.showFavicons$,
);
// When the setting is not enabled, return the first URL
if (!enableFaviconChangePassword) {
return urls[0].href;
}
for (const url of urls) {
const [reliable, wellKnownChangeUrl] = await Promise.all([
this.hasReliableHttpStatusCode(url.origin),
this.getWellKnownChangePasswordUrl(url.origin),
]);
const wellKnownChangeUrl = await this.fetchWellKnownChangePasswordUri(url.href);
// Some servers return a 200 OK for a resource that should not exist
// Which means we cannot trust the well-known URL is valid, so we skip it
// to avoid potentially sending users to a 404 page
if (reliable && wellKnownChangeUrl != null) {
if (wellKnownChangeUrl) {
return wellKnownChangeUrl;
}
}
@@ -60,55 +59,41 @@ export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordSer
}
/**
* Checks if the server returns a non-200 status code for a resource that should not exist.
* See https://w3c.github.io/webappsec-change-password-url/response-code-reliability.html#semantics
* @param urlOrigin The origin of the URL to check
* Fetches the well-known change-password-uri for the given URL.
* @returns The full URL to the change password page, or null if it could not be found.
*/
private async hasReliableHttpStatusCode(urlOrigin: string): Promise<boolean> {
try {
const url = new URL(
"./.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
urlOrigin,
);
private async fetchWellKnownChangePasswordUri(url: string): Promise<string | null> {
const getChangePasswordUriRequest = await this.buildChangePasswordUriRequest(url);
const request = new Request(url, {
method: "GET",
mode: "same-origin",
credentials: "omit",
cache: "no-store",
redirect: "follow",
});
const response = await this.apiService.fetch(getChangePasswordUriRequest);
const response = await this.apiService.nativeFetch(request);
return !response.ok;
} catch {
return false;
if (!response.ok) {
return null;
}
const data = await response.json();
const { uri } = new ChangePasswordUriResponse(data);
return uri;
}
/**
* Builds a well-known change password URL for the given origin. Attempts to fetch the URL to ensure a valid response
* is returned. Returns null if the request throws or the response is not 200 OK.
* See https://w3c.github.io/webappsec-change-password-url/
* @param urlOrigin The origin of the URL to check
* Construct the request for the change-password-uri endpoint.
*/
private async getWellKnownChangePasswordUrl(urlOrigin: string): Promise<string | null> {
try {
const url = new URL("./.well-known/change-password", urlOrigin);
private async buildChangePasswordUriRequest(cipherUri: string): Promise<Request> {
const searchParams = new URLSearchParams();
searchParams.set("uri", cipherUri);
const request = new Request(url, {
method: "GET",
mode: "same-origin",
credentials: "omit",
cache: "no-store",
redirect: "follow",
});
// The change-password-uri endpoint lives within the icons service
// as it uses decrypted cipher data.
const env = await firstValueFrom(this.environmentService.environment$);
const iconsUrl = env.getIconsUrl();
const response = await this.apiService.nativeFetch(request);
const url = new URL(`${iconsUrl}/change-password-uri?${searchParams.toString()}`);
return response.ok ? url.toString() : null;
} catch {
return null;
}
return new Request(url, {
method: "GET",
});
}
}

5
package-lock.json generated
View File

@@ -191,7 +191,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2025.8.1"
"version": "2025.8.2"
},
"apps/cli": {
"name": "@bitwarden/cli",
@@ -277,7 +277,7 @@
},
"apps/desktop": {
"name": "@bitwarden/desktop",
"version": "2025.8.1",
"version": "2025.8.2",
"hasInstallScript": true,
"license": "GPL-3.0"
},
@@ -403,6 +403,7 @@
"license": "GPL-3.0"
},
"libs/state-internal": {
"name": "@bitwarden/state-internal",
"version": "0.0.1",
"license": "GPL-3.0"
},