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:
18
.github/workflows/build-desktop.yml
vendored
18
.github/workflows/build-desktop.yml
vendored
@@ -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:
|
||||
|
||||
108
.github/workflows/publish-desktop.yml
vendored
108
.github/workflows/publish-desktop.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/release-desktop.yml
vendored
3
.github/workflows/release-desktop.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
apps/desktop/.gitignore
vendored
5
apps/desktop/.gitignore
vendored
@@ -3,3 +3,8 @@ dist-safari/
|
||||
*.env
|
||||
PlugIns/safari.appex/
|
||||
xcuserdata/
|
||||
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/README.md
|
||||
fastlane/release_notes/
|
||||
|
||||
174
apps/desktop/fastlane/fastfile
Normal file
174
apps/desktop/fastlane/fastfile
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,7 +31,6 @@ import { SharedModule } from "./shared/shared.module";
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
|
||||
SharedModule,
|
||||
AppRoutingModule,
|
||||
VaultFilterModule,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./shared.module";
|
||||
export * from "./loose-components.module";
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,6 @@
|
||||
[showRowMenuForCriticalApps]="true"
|
||||
[isDrawerIsOpenForThisRecord]="isDrawerOpenForTableRow"
|
||||
[showAppAtRiskMembers]="showAppAtRiskMembers"
|
||||
[unmarkAsCriticalApp]="unmarkAsCriticalApp"
|
||||
[unmarkAsCritical]="unmarkAsCritical"
|
||||
></app-table-row-scrollable>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
};
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,4 +36,4 @@
|
||||
{{ "editProject" | i18n }}
|
||||
</button>
|
||||
</app-header>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet [routerOutletData]="this.project$"></router-outlet>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,4 +65,9 @@
|
||||
select {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* overriding preflight since the apps were built assuming svgs are inline */
|
||||
svg {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { record } from "@bitwarden/serialization/deserialization-helpers";
|
||||
import { record } from "@bitwarden/serialization";
|
||||
|
||||
describe("deserialization helpers", () => {
|
||||
describe("record", () => {
|
||||
|
||||
@@ -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"])(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
5
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user