From 6843e273b8d7a4848ab4a64807647138ebef8f40 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 17 Jul 2025 08:50:07 -0500 Subject: [PATCH 01/54] [PM-23689] Setup Extension Video tweaks (#15620) * add transitions for overlay and top border of video * refactor video container class for readability * update max width for setup-extension page * tweak sizes of videos for larger viewports * fix opacity never changing * remove complex interval transitions --- apps/web/src/app/oss-routing.module.ts | 2 +- .../add-extension-videos.component.html | 13 ++--- .../add-extension-videos.component.spec.ts | 34 ++++++------- .../add-extension-videos.component.ts | 50 ++++++++++++++++++- .../src/anon-layout/anon-layout.component.ts | 4 +- 5 files changed, 73 insertions(+), 30 deletions(-) diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index d3e7fc495ca..8a2270113a9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -610,7 +610,7 @@ const routes: Routes = [ data: { hideCardWrapper: true, hideIcon: true, - maxWidth: "3xl", + maxWidth: "4xl", } satisfies AnonLayoutWrapperData, children: [ { diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html index 3764f7d828f..cd091e11940 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.html @@ -46,10 +46,10 @@ The first video is relatively positioned to force the layout and spacing of the videos. -->
-
+
-
+ +
-
+
{ HTMLMediaElement.prototype.play = play; beforeEach(async () => { - window.matchMedia = jest.fn().mockReturnValue(false); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: false, + addListener() {}, + removeListener() {}, + })), + }); play.mockClear(); await TestBed.configureTestingModule({ @@ -126,45 +133,34 @@ describe("AddExtensionVideosComponent", () => { thirdVideo = component["videoElements"].get(2)!.nativeElement; }); - it("starts the video sequence when all videos are loaded", fakeAsync(() => { - tick(); - + it("starts the video sequence when all videos are loaded", () => { expect(firstVideo.play).toHaveBeenCalled(); - })); - - it("plays videos in sequence", fakeAsync(() => { - tick(); // let first video play + }); + it("plays videos in sequence", () => { play.mockClear(); firstVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(secondVideo.play).toHaveBeenCalledTimes(1); play.mockClear(); secondVideo.onended!(new Event("ended")); // trigger next video - tick(); - expect(thirdVideo.play).toHaveBeenCalledTimes(1); - })); + }); - it("doesn't play videos again when the user prefers no motion", fakeAsync(() => { + it("doesn't play videos again when the user prefers no motion", () => { component["prefersReducedMotion"] = true; - tick(); firstVideo.onended!(new Event("ended")); - tick(); + secondVideo.onended!(new Event("ended")); - tick(); play.mockClear(); thirdVideo.onended!(new Event("ended")); // trigger first video again - tick(); expect(play).toHaveBeenCalledTimes(0); - })); + }); }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts index 2420414fc88..d053e05c36b 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-videos.component.ts @@ -17,6 +17,11 @@ export class AddExtensionVideosComponent { private document = inject(DOCUMENT); + /** CSS variable name tied to the video overlay */ + private cssOverlayVariable = "--overlay-opacity"; + /** CSS variable name tied to the video border */ + private cssBorderVariable = "--border-opacity"; + /** Current viewport size */ protected variant: "mobile" | "desktop" = "desktop"; @@ -26,6 +31,15 @@ export class AddExtensionVideosComponent { /** True when the user prefers reduced motion */ protected prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + /** CSS classes for the video container, pulled into the class only for readability. */ + protected videoContainerClass = [ + "tw-absolute tw-left-0 tw-top-0 tw-w-[15rem] tw-opacity-0 md:tw-opacity-100 md:tw-relative lg:tw-w-[17rem] tw-max-w-full tw-aspect-[0.807]", + `[${this.cssOverlayVariable}:0.7] after:tw-absolute after:tw-top-0 after:tw-left-0 after:tw-size-full after:tw-bg-primary-100 after:tw-content-[''] after:tw-rounded-lg after:tw-opacity-[--overlay-opacity]`, + `[${this.cssBorderVariable}:0] before:tw-absolute before:tw-top-0 before:tw-left-0 before:tw-w-full before:tw-h-2 before:tw-bg-primary-600 before:tw-content-[''] before:tw-rounded-t-lg before:tw-opacity-[--border-opacity]`, + "after:tw-transition-opacity after:tw-duration-400 after:tw-ease-linear", + "before:tw-transition-opacity before:tw-duration-400 before:tw-ease-linear", + ].join(" "); + /** Returns true when all videos are loaded */ get allVideosLoaded(): boolean { return this.numberOfLoadedVideos >= 3; @@ -97,12 +111,14 @@ export class AddExtensionVideosComponent { const video = this.videoElements.toArray()[index].nativeElement; video.onended = () => { void this.startVideoSequence(index + 1); + void this.addPausedStyles(video); }; this.mobileTransitionIn(index); - // Set muted via JavaScript, browsers are respecting autoplay consistently over just the HTML attribute + // Browsers are not respecting autoplay consistently with just the HTML attribute, set via JavaScript as well. video.muted = true; + this.addPlayingStyles(video); await video.play(); } @@ -143,4 +159,36 @@ export class AddExtensionVideosComponent { element.style.transition = transition ? "opacity 0.5s linear" : ""; element.style.opacity = "1"; } + + /** + * Add styles to the video that is moving to the paused/completed state. + * Fade in the overlay and fade out the border. + */ + private addPausedStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 1 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "0"); + // The opacity transitions from 0 to 0.7 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0.7"); + } + + /** + * Add styles to the video that is moving to the playing state. + * Fade out the overlay and fade in the border. + */ + private addPlayingStyles(video: HTMLVideoElement): void { + const parentElement = video.parentElement; + if (!parentElement) { + return; + } + + // The border opacity transitions from 0 to 1 based on the percent complete. + parentElement.style.setProperty(this.cssBorderVariable, "1"); + // The opacity transitions from 0.7 to 0 based on the percent complete. + parentElement.style.setProperty(this.cssOverlayVariable, "0"); + } } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index bb749c2d0b1..355f3aef6eb 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -23,7 +23,7 @@ import { AnonLayoutBitwardenShield } from "../icon/logos"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl"; +export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; @Component({ selector: "auth-anon-layout", @@ -74,6 +74,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return "tw-max-w-2xl"; case "3xl": return "tw-max-w-3xl"; + case "4xl": + return "tw-max-w-4xl"; } } From 127fed70ac2ba955f8c7ab82ad62d233abae7d92 Mon Sep 17 00:00:00 2001 From: Matt Andreko Date: Thu, 17 Jul 2025 10:01:37 -0400 Subject: [PATCH 02/54] Update scan workflow to use centralized reusable component (#15635) --- .github/workflows/scan.yml | 73 ++++++++++---------------------------- 1 file changed, 19 insertions(+), 54 deletions(-) diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 59ef1e0734e..c96dae51c0e 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -10,79 +10,44 @@ on: pull_request: types: [opened, synchronize, reopened] branches-ignore: - - main + - "main" pull_request_target: types: [opened, synchronize, reopened] branches: - "main" +permissions: {} + jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read sast: - name: SAST scan - runs-on: ubuntu-22.04 + name: Checkmarx + uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write security-events: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with Checkmarx - uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41 - env: - INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}" - with: - project_name: ${{ github.repository }} - cx_tenant: ${{ secrets.CHECKMARX_TENANT }} - base_uri: https://ast.checkmarx.net/ - cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }} - cx_client_secret: ${{ secrets.CHECKMARX_SECRET }} - additional_params: | - --report-format sarif \ - --filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \ - --output-path . ${{ env.INCREMENTAL }} - - - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2 - with: - sarif_file: cx_result.sarif - sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} - ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} + id-token: write quality: - name: Quality scan - runs-on: ubuntu-22.04 + name: Sonar + uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main needs: check-run + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read pull-requests: write - - steps: - - name: Check out repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Scan with SonarCloud - uses: sonarsource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.organization=${{ github.repository_owner }} - -Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }} - -Dsonar.tests=. - -Dsonar.sources=. - -Dsonar.test.inclusions=**/*.spec.ts - -Dsonar.exclusions=**/*.spec.ts - -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} + id-token: write \ No newline at end of file From 250e46ee70c8a23eec5aa2967a9f303a6b300983 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Thu, 17 Jul 2025 11:40:22 -0400 Subject: [PATCH 03/54] [PM-23816] Revert aria disabled buttons (#15656) * Revert "[CL-295] Use aria-disabled on buttons (#15009)" This reverts commit 682f1f83d9c211e886d6f05fc557eddb25c4c5c2. * fix import * bring back story fixes --- .../vault-generator-dialog.component.spec.ts | 18 +++-- .../web-generator-dialog.component.spec.ts | 18 +++-- .../src/button/button.component.spec.ts | 14 ++-- .../components/src/button/button.component.ts | 43 ++++-------- .../src/form-field/form-field.component.html | 2 +- .../src/icon-button/icon-button.component.ts | 67 +++++++------------ libs/components/src/link/link.directive.ts | 19 +----- .../src/utils/aria-disable-element.ts | 29 -------- libs/components/src/utils/index.ts | 1 - 9 files changed, 71 insertions(+), 140 deletions(-) delete mode 100644 libs/components/src/utils/aria-disable-element.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index b65138dac3a..b5d35e2005e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -76,8 +76,10 @@ describe("VaultGeneratorDialogComponent", () => { component.onValueGenerated("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe(undefined); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); }); it("should disable the button if no value has been generated", () => { @@ -88,8 +90,10 @@ describe("VaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should disable the button if no algorithm is selected", () => { @@ -100,8 +104,10 @@ describe("VaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should update button text when algorithm is selected", () => { diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts index afb32738901..085a3d0d4b0 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -70,8 +70,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe(undefined); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); }); it("should disable the button if no value has been generated", () => { @@ -82,8 +84,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should disable the button if no algorithm is selected", () => { @@ -94,8 +98,10 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); - expect(button.attributes["aria-disabled"]).toBe("true"); + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); }); it("should close with selected value when confirmed", () => { diff --git a/libs/components/src/button/button.component.spec.ts b/libs/components/src/button/button.component.spec.ts index 1651b6cf12a..6ddbc172803 100644 --- a/libs/components/src/button/button.component.spec.ts +++ b/libs/components/src/button/button.component.spec.ts @@ -34,25 +34,23 @@ describe("Button", () => { expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); }); - it("should be aria-disabled and not html attribute disabled when disabled is true", () => { + it("should be disabled when disabled is true", () => { testAppComponent.disabled = true; fixture.detectChanges(); - expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); - expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); + + expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); // Anchor tags cannot be disabled. }); - it("should be aria-disabled not html attribute disabled when attribute disabled is true", () => { - fixture.detectChanges(); - expect(disabledButtonDebugElement.attributes["aria-disabled"]).toBe("true"); - expect(disabledButtonDebugElement.nativeElement.disabled).toBeFalsy(); + it("should be disabled when attribute disabled is true", () => { + expect(disabledButtonDebugElement.nativeElement.disabled).toBeTruthy(); }); it("should be disabled when loading is true", () => { testAppComponent.loading = true; fixture.detectChanges(); - expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); + expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); }); }); diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index a1b35608f25..635c269bd0f 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,20 +1,9 @@ import { NgClass } from "@angular/common"; -import { - HostBinding, - Component, - model, - computed, - input, - ElementRef, - inject, - Signal, - booleanAttribute, -} from "@angular/core"; +import { input, HostBinding, Component, model, computed, booleanAttribute } from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { debounce, interval } from "rxjs"; import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction"; -import { ariaDisableElement } from "../utils"; const focusRing = [ "focus-visible:tw-ring-2", @@ -62,7 +51,7 @@ const buttonStyles: Record = { providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], imports: [NgClass], host: { - "[attr.aria-disabled]": "disabledAttr()", + "[attr.disabled]": "disabledAttr()", }, }) export class ButtonComponent implements ButtonLikeAbstraction { @@ -79,28 +68,27 @@ export class ButtonComponent implements ButtonLikeAbstraction { "focus:tw-outline-none", ] .concat(this.block() ? ["tw-w-full", "tw-block"] : ["tw-inline-block"]) + .concat(buttonStyles[this.buttonType() ?? "secondary"]) .concat( this.showDisabledStyles() || this.disabled() ? [ - "aria-disabled:!tw-bg-secondary-300", - "hover:tw-bg-secondary-300", - "aria-disabled:tw-border-secondary-300", - "hover:tw-border-secondary-300", - "aria-disabled:!tw-text-muted", - "hover:!tw-text-muted", - "aria-disabled:tw-cursor-not-allowed", - "hover:tw-no-underline", - "aria-disabled:tw-pointer-events-none", + "disabled:tw-bg-secondary-300", + "disabled:hover:tw-bg-secondary-300", + "disabled:tw-border-secondary-300", + "disabled:hover:tw-border-secondary-300", + "disabled:!tw-text-muted", + "disabled:hover:!tw-text-muted", + "disabled:tw-cursor-not-allowed", + "disabled:hover:tw-no-underline", ] : [], ) - .concat(buttonStyles[this.buttonType() ?? "secondary"]) .concat(buttonSizeStyles[this.size() || "default"]); } protected disabledAttr = computed(() => { const disabled = this.disabled() != null && this.disabled() !== false; - return disabled || this.loading() ? true : undefined; + return disabled || this.loading() ? true : null; }); /** @@ -139,10 +127,5 @@ export class ButtonComponent implements ButtonLikeAbstraction { toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))), ); - readonly disabled = model(false); - private el = inject(ElementRef); - - constructor() { - ariaDisableElement(this.el.nativeElement, this.disabledAttr as Signal); - } + disabled = model(false); } diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index ccea0546f3a..c4fd018b3ba 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -46,7 +46,7 @@
= { const disabledStyles: Record = { contrast: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], main: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], muted: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], primary: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-primary-600", - "aria-disabled:hover:tw-bg-primary-600", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-primary-600", + "disabled:hover:tw-bg-primary-600", ], secondary: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-text-muted", - "aria-disabled:hover:tw-bg-transparent", - "aria-disabled:hover:!tw-text-muted", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-text-muted", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-muted", ], danger: [ - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", - "aria-disabled:hover:!tw-text-secondary-300", + "disabled:!tw-text-secondary-300", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", + "disabled:hover:!tw-text-secondary-300", ], light: [ - "aria-disabled:tw-opacity-60", - "aria-disabled:hover:tw-border-transparent", - "aria-disabled:hover:tw-bg-transparent", + "disabled:tw-opacity-60", + "disabled:hover:tw-border-transparent", + "disabled:hover:tw-bg-transparent", ], unstyled: [], }; @@ -173,7 +163,7 @@ const sizes: Record = { ], imports: [NgClass], host: { - "[attr.aria-disabled]": "disabledAttr()", + "[attr.disabled]": "disabledAttr()", }, }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { @@ -245,10 +235,5 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - private elementRef = inject(ElementRef); - - constructor() { - const element = this.elementRef.nativeElement; - ariaDisableElement(element, this.disabledAttr as Signal); - } + constructor(private elementRef: ElementRef) {} } diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index aced89fc7b3..f2eb44bc3a4 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -1,6 +1,4 @@ -import { HostBinding, Directive, inject, ElementRef, input, booleanAttribute } from "@angular/core"; - -import { ariaDisableElement } from "../utils"; +import { input, HostBinding, Directive } from "@angular/core"; export type LinkType = "primary" | "secondary" | "contrast" | "light"; @@ -60,11 +58,6 @@ const commonStyles = [ "before:tw-transition", "focus-visible:before:tw-ring-2", "focus-visible:tw-z-10", - "aria-disabled:tw-no-underline", - "aria-disabled:tw-pointer-events-none", - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:!tw-text-secondary-300", - "aria-disabled:hover:tw-no-underline", ]; @Directive() @@ -95,19 +88,9 @@ export class AnchorLinkDirective extends LinkDirective { selector: "button[bitLink]", }) export class ButtonLinkDirective extends LinkDirective { - private el = inject(ElementRef); - - disabled = input(false, { transform: booleanAttribute }); - @HostBinding("class") get classList() { return ["before:-tw-inset-y-[0.25rem]"] .concat(commonStyles) .concat(linkStyles[this.linkType()] ?? []); } - - constructor() { - super(); - - ariaDisableElement(this.el.nativeElement, this.disabled); - } } diff --git a/libs/components/src/utils/aria-disable-element.ts b/libs/components/src/utils/aria-disable-element.ts deleted file mode 100644 index f7e02f2cdd1..00000000000 --- a/libs/components/src/utils/aria-disable-element.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Signal, effect } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { fromEvent } from "rxjs"; - -/** - * a11y helper util used to `aria-disable` elements as opposed to using the HTML `disabled` attr. - * - Removes HTML `disabled` attr and replaces it with `aria-disabled="true"` - * - Captures click events and prevents them from propagating - */ -export function ariaDisableElement(element: HTMLElement, isDisabled: Signal) { - effect(() => { - if (element.hasAttribute("disabled") || isDisabled()) { - // Remove native disabled and set aria-disabled. Capture click event - element.removeAttribute("disabled"); - - element.setAttribute("aria-disabled", "true"); - } - }); - - fromEvent(element, "click") - .pipe(takeUntilDestroyed()) - .subscribe((event: Event) => { - if (isDisabled()) { - event.stopPropagation(); - event.preventDefault(); - return false; - } - }); -} diff --git a/libs/components/src/utils/index.ts b/libs/components/src/utils/index.ts index 91fa71cf0e0..afadd6b3b41 100644 --- a/libs/components/src/utils/index.ts +++ b/libs/components/src/utils/index.ts @@ -1,3 +1,2 @@ -export * from "./aria-disable-element"; export * from "./function-to-observable"; export * from "./i18n-mock.service"; From 00b6b0224e1d04dde33bd0d7069e57f5329b408c Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:43:49 -0500 Subject: [PATCH 04/54] feat(extension-login-approvals): [Auth/PM-14939] devices list view for browser (#14620) Creates a new `DeviceManagementComponent` that fetches devices and formats them before handing them off to a view component for display. View components: - `DeviceManagementTableComponent` - displays on medium to large screens - `DeviceManagementItemGroupComponent` - displays on small screens Feature flag: `PM14938_BrowserExtensionLoginApproval` --- apps/browser/src/_locales/en/messages.json | 98 ++++++++ .../settings/account-security.component.html | 12 + .../settings/account-security.component.ts | 8 + ...extension-device-management.component.html | 11 + .../extension-device-management.component.ts | 22 ++ ...ion-device-management-component.service.ts | 15 ++ .../browser/src/background/main.background.ts | 3 +- apps/browser/src/popup/app-routing.module.ts | 7 + .../src/popup/services/services.module.ts | 7 + ...l => device-management-old.component.html} | 0 ...> device-management-old.component.spec.ts} | 10 +- ....ts => device-management-old.component.ts} | 4 +- .../security/security-routing.module.ts | 18 +- apps/web/src/app/core/core.module.ts | 7 + apps/web/src/locales/en/messages.json | 22 ++ ...ult-device-management-component.service.ts | 15 ++ ...anagement-component.service.abstraction.ts | 11 + ...evice-management-item-group.component.html | 63 +++++ .../device-management-item-group.component.ts | 44 ++++ .../device-management-table.component.html | 62 +++++ .../device-management-table.component.ts | 86 +++++++ .../device-management.component.html | 40 +++ .../device-management.component.ts | 230 ++++++++++++++++++ .../resort-devices.helper.ts | 53 ++++ .../src/services/jslib-services.module.ts | 2 +- .../devices/devices.service.abstraction.ts | 3 + .../devices/devices.service.implementation.ts | 25 +- libs/common/src/enums/device-type.enum.ts | 20 +- 28 files changed, 872 insertions(+), 26 deletions(-) create mode 100644 apps/browser/src/auth/popup/settings/extension-device-management.component.html create mode 100644 apps/browser/src/auth/popup/settings/extension-device-management.component.ts create mode 100644 apps/browser/src/auth/services/extension-device-management-component.service.ts rename apps/web/src/app/auth/settings/security/{device-management.component.html => device-management-old.component.html} (100%) rename apps/web/src/app/auth/settings/security/{device-management.component.spec.ts => device-management-old.component.spec.ts} (95%) rename apps/web/src/app/auth/settings/security/{device-management.component.ts => device-management-old.component.ts} (99%) create mode 100644 libs/angular/src/auth/device-management/default-device-management-component.service.ts create mode 100644 libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts create mode 100644 libs/angular/src/auth/device-management/device-management-item-group.component.html create mode 100644 libs/angular/src/auth/device-management/device-management-item-group.component.ts create mode 100644 libs/angular/src/auth/device-management/device-management-table.component.html create mode 100644 libs/angular/src/auth/device-management/device-management-table.component.ts create mode 100644 libs/angular/src/auth/device-management/device-management.component.html create mode 100644 libs/angular/src/auth/device-management/device-management.component.ts create mode 100644 libs/angular/src/auth/device-management/resort-devices.helper.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ee8cd412625..11d13392ce2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3460,6 +3460,22 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3572,88 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index d835497d9be..3de1cc81a69 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -102,6 +102,18 @@ + + +

{{ "manageDevices" | i18n }}

+
+ + + +
+

{{ "otherOptions" | i18n }}

diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 4f9e1f7414a..6c072532a5d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { VaultTimeout, VaultTimeoutAction, @@ -40,6 +41,7 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -113,6 +115,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { biometricUnavailabilityReason: string; showChangeMasterPass = true; pinEnabled$: Observable = of(true); + extensionLoginApprovalFlagEnabled = false; form = this.formBuilder.group({ vaultTimeout: [null as VaultTimeout | null], @@ -155,6 +158,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private biometricsService: BiometricsService, private vaultNudgesService: NudgesService, private validationService: ValidationService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -235,6 +239,10 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }; this.form.patchValue(initialValues, { emitEvent: false }); + this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM14938_BrowserExtensionLoginApproval, + ); + timer(0, 1000) .pipe( switchMap(async () => { diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.html b/apps/browser/src/auth/popup/settings/extension-device-management.component.html new file mode 100644 index 00000000000..aadbe6b81d0 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.html @@ -0,0 +1,11 @@ + + + + + + + +
+ +
+
diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts new file mode 100644 index 00000000000..793965db141 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts @@ -0,0 +1,22 @@ +import { Component } from "@angular/core"; + +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +@Component({ + standalone: true, + selector: "extension-device-management", + templateUrl: "extension-device-management.component.html", + imports: [ + DeviceManagementComponent, + I18nPipe, + PopOutComponent, + PopupHeaderComponent, + PopupPageComponent, + ], +}) +export class ExtensionDeviceManagementComponent {} diff --git a/apps/browser/src/auth/services/extension-device-management-component.service.ts b/apps/browser/src/auth/services/extension-device-management-component.service.ts new file mode 100644 index 00000000000..2585ba3198c --- /dev/null +++ b/apps/browser/src/auth/services/extension-device-management-component.service.ts @@ -0,0 +1,15 @@ +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; + +/** + * Browser extension implementation of the device management component service + */ +export class ExtensionDeviceManagementComponentService + implements DeviceManagementComponentServiceAbstraction +{ + /** + * Don't show header information in browser extension client + */ + showHeaderInformation(): boolean { + return false; + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0d7fe740069..1dfc947b284 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -814,8 +814,9 @@ export default class MainBackground { ); this.devicesService = new DevicesServiceImplementation( - this.devicesApiService, this.appIdService, + this.devicesApiService, + this.i18nService, ); this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index da5a6c43d36..47ba2326557 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -49,6 +49,7 @@ import { AccountSwitcherComponent } from "../auth/popup/account-switching/accoun import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; +import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; @@ -263,6 +264,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, + { + path: "device-management", + component: ExtensionDeviceManagementComponent, + canActivate: [authGuard], + data: { elevation: 1 } satisfies RouteDataProperties, + }, { path: "notifications", component: NotificationsSettingsComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 54d09ab9d8c..3887c8c8b12 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -4,6 +4,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { merge, of, Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; @@ -145,6 +146,7 @@ import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service"; import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.service"; +import { ExtensionDeviceManagementComponentService } from "../../auth/services/extension-device-management-component.service"; import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service"; import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service"; import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service"; @@ -667,6 +669,11 @@ const safeProviders: SafeProvider[] = [ useClass: ForegroundNotificationsService, deps: [LogService], }), + safeProvider({ + provide: DeviceManagementComponentServiceAbstraction, + useClass: ExtensionDeviceManagementComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management-old.component.html similarity index 100% rename from apps/web/src/app/auth/settings/security/device-management.component.html rename to apps/web/src/app/auth/settings/security/device-management-old.component.html diff --git a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts similarity index 95% rename from apps/web/src/app/auth/settings/security/device-management.component.spec.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts index 2821d4a6d76..64fb9003ccf 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts @@ -20,7 +20,7 @@ import { import { SharedModule } from "../../../shared"; import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service"; -import { DeviceManagementComponent } from "./device-management.component"; +import { DeviceManagementOldComponent } from "./device-management-old.component"; class MockResizeObserver { observe = jest.fn(); @@ -35,8 +35,8 @@ interface Message { notificationId?: string; } -describe("DeviceManagementComponent", () => { - let fixture: ComponentFixture; +describe("DeviceManagementOldComponent", () => { + let fixture: ComponentFixture; let messageSubject: Subject; let mockDevices: DeviceView[]; let vaultBannersService: VaultBannersService; @@ -66,7 +66,7 @@ describe("DeviceManagementComponent", () => { SharedModule, TableModule, PopoverModule, - DeviceManagementComponent, + DeviceManagementOldComponent, ], providers: [ { @@ -130,7 +130,7 @@ describe("DeviceManagementComponent", () => { ], }).compileComponents(); - fixture = TestBed.createComponent(DeviceManagementComponent); + fixture = TestBed.createComponent(DeviceManagementOldComponent); vaultBannersService = TestBed.inject(VaultBannersService); }); diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts similarity index 99% rename from apps/web/src/app/auth/settings/security/device-management.component.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.ts index 854a13faa99..556ba381acc 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.ts @@ -45,10 +45,10 @@ interface DeviceTableData { */ @Component({ selector: "app-device-management", - templateUrl: "./device-management.component.html", + templateUrl: "./device-management-old.component.html", imports: [CommonModule, SharedModule, TableModule, PopoverModule], }) -export class DeviceManagementComponent { +export class DeviceManagementOldComponent { protected dataSource = new TableDataSource(); protected currentDevice: DeviceView | undefined; protected loading = true; diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index 14d4aab8a36..2ec1be5cb7f 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -1,13 +1,15 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ChangePasswordComponent } from "../change-password.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; -import { DeviceManagementComponent } from "./device-management.component"; +import { DeviceManagementOldComponent } from "./device-management-old.component"; import { PasswordSettingsComponent } from "./password-settings/password-settings.component"; import { SecurityKeysComponent } from "./security-keys.component"; import { SecurityComponent } from "./security.component"; @@ -55,11 +57,15 @@ const routes: Routes = [ component: SecurityKeysComponent, data: { titleId: "keys" }, }, - { - path: "device-management", - component: DeviceManagementComponent, - data: { titleId: "devices" }, - }, + ...featureFlaggedRoute({ + defaultComponent: DeviceManagementOldComponent, + flaggedComponent: DeviceManagementComponent, + featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval, + routeOptions: { + path: "device-management", + data: { titleId: "devices" }, + }, + }), ], }, ]; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 9cfe3117d40..d98a2ee8cf2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -10,6 +10,8 @@ import { OrganizationUserApiService, CollectionService, } from "@bitwarden/admin-console/common"; +import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service"; +import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; @@ -406,6 +408,11 @@ const safeProviders: SafeProvider[] = [ RouterService, ], }), + safeProvider({ + provide: DeviceManagementComponentServiceAbstraction, + useClass: DefaultDeviceManagementComponentService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a58126adac5..475fb004033 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3489,6 +3489,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3971,6 +3974,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4115,6 +4134,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/libs/angular/src/auth/device-management/default-device-management-component.service.ts b/libs/angular/src/auth/device-management/default-device-management-component.service.ts new file mode 100644 index 00000000000..5089ba259a5 --- /dev/null +++ b/libs/angular/src/auth/device-management/default-device-management-component.service.ts @@ -0,0 +1,15 @@ +import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction"; + +/** + * Default implementation of the device management component service + */ +export class DefaultDeviceManagementComponentService + implements DeviceManagementComponentServiceAbstraction +{ + /** + * Show header information in web client + */ + showHeaderInformation(): boolean { + return true; + } +} diff --git a/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts new file mode 100644 index 00000000000..02834908658 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts @@ -0,0 +1,11 @@ +/** + * Service abstraction for device management component + * Used to determine client-specific behavior + */ +export abstract class DeviceManagementComponentServiceAbstraction { + /** + * Whether to show header information (title, description, etc.) in the device management component + * @returns true if header information should be shown, false otherwise + */ + abstract showHeaderInformation(): boolean; +} diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.html b/libs/angular/src/auth/device-management/device-management-item-group.component.html new file mode 100644 index 00000000000..b47408059a2 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.html @@ -0,0 +1,63 @@ + + + @if (device.pendingAuthRequest) { + + } @else { + + + {{ device.displayName }} + + +
+ + {{ "currentSession" | i18n }} + +
+ + +
+ @if (device.isTrusted) { + {{ "trusted" | i18n }} + } @else { +
+ } + +
+ {{ "firstLogin" | i18n }}: + {{ device.firstLogin | date: "medium" }} +
+
+
+ } +
+
diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts new file mode 100644 index 00000000000..62468a18225 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { DeviceDisplayData } from "./device-management.component"; +import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; + +/** Displays user devices in an item list view */ +@Component({ + standalone: true, + selector: "auth-device-management-item-group", + templateUrl: "./device-management-item-group.component.html", + imports: [BadgeModule, CommonModule, ItemModule, I18nPipe], +}) +export class DeviceManagementItemGroupComponent { + @Input() devices: DeviceDisplayData[] = []; + + constructor(private dialogService: DialogService) {} + + protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) { + if (pendingAuthRequest == null) { + return; + } + + const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + notificationId: pendingAuthRequest.id, + }); + + const result = await firstValueFrom(loginApprovalDialog.closed); + + if (result !== undefined && typeof result === "boolean") { + // Auth request was approved or denied, so clear the + // pending auth request and re-sort the device array + this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management-table.component.html b/libs/angular/src/auth/device-management/device-management-table.component.html new file mode 100644 index 00000000000..febb0a96a4e --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.html @@ -0,0 +1,62 @@ + + + + + {{ column.title }} + + + + + + + +
+ +
+ +
+ @if (device.pendingAuthRequest) { + + {{ device.displayName }} + +
+ {{ "needsApproval" | i18n }} +
+ } @else { + {{ device.displayName }} +
+ {{ "trusted" | i18n }} +
+ } +
+ + + + +
+ + {{ "currentSession" | i18n }} + + + {{ "requestPending" | i18n }} + +
+ + + + {{ device.firstLogin | date: "medium" }} +
+
diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts new file mode 100644 index 00000000000..1d20e54deec --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + ButtonModule, + DialogService, + LinkModule, + TableDataSource, + TableModule, +} from "@bitwarden/components"; + +import { DeviceDisplayData } from "./device-management.component"; +import { clearAuthRequestAndResortDevices } from "./resort-devices.helper"; + +/** Displays user devices in a sortable table view */ +@Component({ + standalone: true, + selector: "auth-device-management-table", + templateUrl: "./device-management-table.component.html", + imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule], +}) +export class DeviceManagementTableComponent implements OnChanges { + @Input() devices: DeviceDisplayData[] = []; + protected tableDataSource = new TableDataSource(); + + protected readonly columnConfig = [ + { + name: "displayName", + title: this.i18nService.t("device"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "loginStatus", + title: this.i18nService.t("loginStatus"), + headerClass: "tw-w-1/3", + sortable: true, + }, + { + name: "firstLogin", + title: this.i18nService.t("firstLogin"), + headerClass: "tw-w-1/3", + sortable: true, + }, + ]; + + constructor( + private i18nService: I18nService, + private dialogService: DialogService, + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes.devices) { + this.tableDataSource.data = this.devices; + } + } + + protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) { + if (pendingAuthRequest == null) { + return; + } + + const loginApprovalDialog = LoginApprovalComponent.open(this.dialogService, { + notificationId: pendingAuthRequest.id, + }); + + const result = await firstValueFrom(loginApprovalDialog.closed); + + if (result !== undefined && typeof result === "boolean") { + // Auth request was approved or denied, so clear the + // pending auth request and re-sort the device array + this.tableDataSource.data = clearAuthRequestAndResortDevices( + this.devices, + pendingAuthRequest, + ); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management.component.html b/libs/angular/src/auth/device-management/device-management.component.html new file mode 100644 index 00000000000..8b82140a508 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.html @@ -0,0 +1,40 @@ +
+
+

{{ "devices" | i18n }}

+ + + + +

{{ "aDeviceIs" | i18n }}

+
+
+ +

+ {{ "deviceListDescriptionTemp" | i18n }} +

+
+ +@if (initializing) { +
+ +
+} @else { + + + + + +} diff --git a/libs/angular/src/auth/device-management/device-management.component.ts b/libs/angular/src/auth/device-management/device-management.component.ts new file mode 100644 index 00000000000..dc7700a9410 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.ts @@ -0,0 +1,230 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { + DevicePendingAuthRequest, + DeviceResponse, +} from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; +import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { ButtonModule, PopoverModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction"; +import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component"; +import { DeviceManagementTableComponent } from "./device-management-table.component"; + +export interface DeviceDisplayData { + displayName: string; + firstLogin: Date; + icon: string; + id: string; + identifier: string; + isCurrentDevice: boolean; + isTrusted: boolean; + loginStatus: string; + pendingAuthRequest: DevicePendingAuthRequest | null; +} + +/** + * The `DeviceManagementComponent` fetches user devices and passes them down + * to a child component for display. + * + * The specific child component that gets displayed depends on the viewport width: + * - Medium to Large screens = `bit-table` view + * - Small screens = `bit-item-group` view + */ +@Component({ + standalone: true, + selector: "auth-device-management", + templateUrl: "./device-management.component.html", + imports: [ + ButtonModule, + CommonModule, + DeviceManagementItemGroupComponent, + DeviceManagementTableComponent, + I18nPipe, + PopoverModule, + ], +}) +export class DeviceManagementComponent implements OnInit { + protected devices: DeviceDisplayData[] = []; + protected initializing = true; + protected showHeaderInfo = false; + + constructor( + private authRequestApiService: AuthRequestApiServiceAbstraction, + private destroyRef: DestroyRef, + private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction, + private devicesService: DevicesServiceAbstraction, + private i18nService: I18nService, + private messageListener: MessageListener, + private validationService: ValidationService, + ) { + this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation(); + } + + async ngOnInit() { + await this.loadDevices(); + + this.messageListener.allMessages$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((message) => { + if ( + message.command === "openLoginApproval" && + message.notificationId && + typeof message.notificationId === "string" + ) { + void this.upsertDeviceWithPendingAuthRequest(message.notificationId); + } + }); + } + + async loadDevices() { + try { + const devices = await firstValueFrom(this.devicesService.getDevices$()); + const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$()); + + if (!devices || !currentDevice) { + return; + } + + this.devices = this.mapDevicesToDisplayData(devices, currentDevice); + } catch (e) { + this.validationService.showError(e); + } finally { + this.initializing = false; + } + } + + private mapDevicesToDisplayData( + devices: DeviceView[], + currentDevice: DeviceResponse, + ): DeviceDisplayData[] { + return devices + .map((device): DeviceDisplayData | null => { + if (!device.id) { + this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing"))); + return null; + } + + if (device.type == undefined) { + this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing"))); + return null; + } + + if (!device.creationDate) { + this.validationService.showError( + new Error(this.i18nService.t("deviceCreationDateMissing")), + ); + return null; + } + + return { + displayName: this.devicesService.getReadableDeviceTypeName(device.type), + firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(), + icon: this.getDeviceIcon(device.type), + id: device.id || "", + identifier: device.identifier ?? "", + isCurrentDevice: this.isCurrentDevice(device, currentDevice), + isTrusted: device.response?.isTrusted ?? false, + loginStatus: this.getLoginStatus(device, currentDevice), + pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null, + }; + }) + .filter((device) => device !== null); + } + + private async upsertDeviceWithPendingAuthRequest(authRequestId: string) { + const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId); + if (!authRequestResponse) { + return; + } + + const upsertDevice: DeviceDisplayData = { + displayName: this.devicesService.getReadableDeviceTypeName( + authRequestResponse.requestDeviceTypeValue, + ), + firstLogin: new Date(authRequestResponse.creationDate), + icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue), + id: "", + identifier: authRequestResponse.requestDeviceIdentifier, + isCurrentDevice: false, + isTrusted: false, + loginStatus: this.i18nService.t("requestPending"), + pendingAuthRequest: { + id: authRequestResponse.id, + creationDate: authRequestResponse.creationDate, + }, + }; + + // If the device already exists in the DB, update the device id and first login date + if (authRequestResponse.requestDeviceIdentifier) { + const existingDevice = await firstValueFrom( + this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier), + ); + + if (existingDevice?.id && existingDevice.creationDate) { + upsertDevice.id = existingDevice.id; + upsertDevice.firstLogin = new Date(existingDevice.creationDate); + } + } + + const existingDeviceIndex = this.devices.findIndex( + (device) => device.identifier === upsertDevice.identifier, + ); + + if (existingDeviceIndex >= 0) { + // Update existing device in device list + this.devices[existingDeviceIndex] = upsertDevice; + this.devices = [...this.devices]; + } else { + // Add new device to device list + this.devices = [upsertDevice, ...this.devices]; + } + } + + private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string { + if (this.isCurrentDevice(device, currentDevice)) { + return this.i18nService.t("currentSession"); + } + + if (this.hasPendingAuthRequest(device)) { + return this.i18nService.t("requestPending"); + } + + return ""; + } + + private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean { + return device.id === currentDevice.id; + } + + private hasPendingAuthRequest(device: DeviceView): boolean { + return device.response?.devicePendingAuthRequest != null; + } + + private getDeviceIcon(type: DeviceType): string { + const defaultIcon = "bwi bwi-desktop"; + const categoryIconMap: Record = { + webApp: "bwi bwi-browser", + desktop: "bwi bwi-desktop", + mobile: "bwi bwi-mobile", + cli: "bwi bwi-cli", + extension: "bwi bwi-puzzle", + sdk: "bwi bwi-desktop", + }; + + const metadata = DeviceTypeMetadata[type]; + return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon; + } +} diff --git a/libs/angular/src/auth/device-management/resort-devices.helper.ts b/libs/angular/src/auth/device-management/resort-devices.helper.ts new file mode 100644 index 00000000000..e739e943ee8 --- /dev/null +++ b/libs/angular/src/auth/device-management/resort-devices.helper.ts @@ -0,0 +1,53 @@ +import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; + +import { DeviceDisplayData } from "./device-management.component"; + +export function clearAuthRequestAndResortDevices( + devices: DeviceDisplayData[], + pendingAuthRequest: DevicePendingAuthRequest, +): DeviceDisplayData[] { + return devices + .map((device) => { + if (device.pendingAuthRequest?.id === pendingAuthRequest.id) { + device.pendingAuthRequest = null; + device.loginStatus = ""; + } + return device; + }) + .sort(resortDevices); +} + +/** + * After a device is approved/denied, it will still be at the beginning of the array, + * so we must resort the array to ensure it is in the correct order. + * + * This is a helper function that gets passed to the `Array.sort()` method + */ +function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) { + // Devices with a pending auth request should be first + if (deviceA.pendingAuthRequest) { + return -1; + } + if (deviceB.pendingAuthRequest) { + return 1; + } + + // Next is the current device + if (deviceA.isCurrentDevice) { + return -1; + } + if (deviceB.isCurrentDevice) { + return 1; + } + + // Then sort the rest by display name (alphabetically) + if (deviceA.displayName < deviceB.displayName) { + return -1; + } + if (deviceA.displayName > deviceB.displayName) { + return 1; + } + + // Default + return 0; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 391c20b30d6..0ad7b57d9b3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1188,7 +1188,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DevicesServiceAbstraction, useClass: DevicesServiceImplementation, - deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction], + deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction], }), safeProvider({ provide: AuthRequestApiServiceAbstraction, diff --git a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts index ba6890947c1..8c1fa61322b 100644 --- a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts @@ -1,5 +1,7 @@ import { Observable } from "rxjs"; +import { DeviceType } from "@bitwarden/common/enums"; + import { DeviceResponse } from "./responses/device.response"; import { DeviceView } from "./views/device.view"; @@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction { ): Observable; abstract deactivateDevice$(deviceId: string): Observable; abstract getCurrentDevice$(): Observable; + abstract getReadableDeviceTypeName(deviceType: DeviceType): string; } diff --git a/libs/common/src/auth/services/devices/devices.service.implementation.ts b/libs/common/src/auth/services/devices/devices.service.implementation.ts index cdaa7a9fc4e..ba9b376576e 100644 --- a/libs/common/src/auth/services/devices/devices.service.implementation.ts +++ b/libs/common/src/auth/services/devices/devices.service.implementation.ts @@ -1,5 +1,8 @@ import { Observable, defer, map } from "rxjs"; +import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { ListResponse } from "../../../models/response/list.response"; import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction"; @@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser */ export class DevicesServiceImplementation implements DevicesServiceAbstraction { constructor( - private devicesApiService: DevicesApiServiceAbstraction, private appIdService: AppIdService, + private devicesApiService: DevicesApiServiceAbstraction, + private i18nService: I18nService, ) {} /** @@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction { return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier); }); } + + /** + * @description Gets a human readable string of the device type name + */ + getReadableDeviceTypeName(type: DeviceType): string { + if (type === undefined) { + return this.i18nService.t("unknownDevice"); + } + + const metadata = DeviceTypeMetadata[type]; + if (!metadata) { + return this.i18nService.t("unknownDevice"); + } + + const platform = + metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform; + const category = this.i18nService.t(metadata.category); + return platform ? `${category} - ${platform}` : category; + } } diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index c462081140e..f7215ac7446 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -35,7 +35,7 @@ export enum DeviceType { * Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.) */ interface DeviceTypeMetadata { - category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server"; + category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server"; platform: string; } @@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record = { [DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" }, [DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" }, [DeviceType.SafariExtension]: { category: "extension", platform: "Safari" }, - [DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" }, - [DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" }, - [DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" }, - [DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" }, - [DeviceType.IEBrowser]: { category: "webVault", platform: "IE" }, - [DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" }, - [DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" }, - [DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" }, - [DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" }, + [DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" }, + [DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" }, + [DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" }, + [DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" }, + [DeviceType.IEBrowser]: { category: "webApp", platform: "IE" }, + [DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" }, + [DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" }, + [DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" }, + [DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" }, [DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" }, [DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" }, [DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" }, From b4120e0e3f83e5cbbe9ecb258c9a67d364475158 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:55:32 -0500 Subject: [PATCH 05/54] [PM-22134] Migrate list views to `CipherListView` from the SDK (#15174) * add `CipherViewLike` and utilities to handle `CipherView` and `CipherViewLike` * migrate libs needed for web vault to support `CipherViewLike` * migrate web vault components to support * add for CipherView. will have to be later * fetch full CipherView for copying a password * have only the cipher service utilize SDK migration flag - This keeps feature flag logic away from the component - Also cuts down on what is needed for other platforms * strongly type CipherView for AC vault - Probably temporary before migration of the AC vault to `CipherListView` SDK * fix build icon tests by being more gracious with the uri structure * migrate desktop components to CipherListViews$ * consume card from sdk * add browser implementation for `CipherListView` * update copy message for single copiable items * refactor `getCipherViewLikeLogin` to `getLogin` * refactor `getCipherViewLikeCard` to `getCard` * add `hasFido2Credentials` helper * add decryption failure to cipher like utils * add todo with ticket * fix decryption failure typing * fix copy card messages * fix addition of organizations and collections for `PopupCipherViewLike` - accessors were being lost * refactor to getters to fix re-rendering bug * fix decryption failure helper * fix sorting functions for `CipherViewLike` * formatting * add `CipherViewLikeUtils` tests * refactor "copiable" to "copyable" to match SDK * use `hasOldAttachments` from cipherlistview * fix typing * update SDK version * add feature flag for cipher list view work * use `CipherViewLikeUtils` for copyable values rather than referring to the cipher directly * update restricted item type to support CipherViewLike * add cipher support to `CipherViewLikeUtils` * update `isCipherListView` check * refactor CipherLike to a separate type * refactor `getFullCipherView` into the cipher service * add optional chaining for `uriChecksum` * set empty array for decrypted CipherListView * migrate nudge service to use `cipherListViews` * update web vault to not depend on `cipherViews$` * update popup list filters to use `CipherListView` * fix storybook * fix tests * accept undefined as a MY VAULT filter value for cipher list views * use `LoginUriView` for uri logic (#15530) * filter out null ciphers from the `_allDecryptedCiphers$` (#15539) * use `launchUri` to avoid any unexpected behavior in URIs - this appends `http://` when missing --- apps/browser/src/_locales/en/messages.json | 11 +- .../autofill-vault-list-items.component.ts | 9 +- .../item-copy-actions.component.html | 40 +- .../item-copy-actions.component.ts | 161 +++-- .../item-more-options.component.html | 2 +- .../item-more-options.component.ts | 37 +- .../vault-list-items-container.component.html | 9 +- .../vault-list-items-container.component.ts | 60 +- .../vault-popup-items.service.spec.ts | 41 +- .../services/vault-popup-items.service.ts | 126 ++-- .../vault-popup-list-filters.service.spec.ts | 37 +- .../vault-popup-list-filters.service.ts | 79 ++- .../trash-list-items-container.component.ts | 12 +- .../vault/popup/views/popup-cipher.view.ts | 28 +- apps/desktop/src/locales/en/messages.json | 17 + .../app/vault/vault-items-v2.component.html | 6 +- .../app/vault/vault-items-v2.component.ts | 12 +- .../src/vault/app/vault/vault-v2.component.ts | 21 +- .../collections/vault.component.ts | 4 +- .../vault-item-dialog.component.ts | 3 +- .../vault-cipher-row.component.html | 29 +- .../vault-items/vault-cipher-row.component.ts | 74 ++- .../vault-collection-row.component.ts | 5 +- .../vault-items/vault-item-event.ts | 20 +- .../components/vault-items/vault-item.ts | 6 +- .../vault-items/vault-items.component.ts | 61 +- .../vault-items/vault-items.stories.ts | 3 +- .../services/vault-filter.service.spec.ts | 2 +- .../services/vault-filter.service.ts | 5 +- .../shared/models/filter-function.spec.ts | 2 +- .../shared/models/filter-function.ts | 26 +- .../vault-onboarding.component.ts | 4 +- .../vault/individual-vault/vault.component.ts | 111 +++- apps/web/src/locales/en/messages.json | 17 + .../src/vault/components/icon.component.ts | 4 +- .../vault/components/vault-items.component.ts | 38 +- .../empty-vault-nudge.service.ts | 2 +- .../vault-filter/models/vault-filter.model.ts | 13 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/vault/abstractions/cipher.service.ts | 21 +- .../src/vault/abstractions/search.service.ts | 15 +- .../src/vault/icon/build-cipher-icon.ts | 18 +- .../services/cipher-authorization.service.ts | 12 +- .../src/vault/services/cipher.service.ts | 100 ++- .../services/restricted-item-types.service.ts | 10 +- .../src/vault/services/search.service.ts | 31 +- libs/common/src/vault/types/cipher-like.ts | 9 + .../utils/cipher-view-like-utils.spec.ts | 624 ++++++++++++++++++ .../src/vault/utils/cipher-view-like-utils.ts | 301 +++++++++ .../copy-cipher-field.directive.spec.ts | 59 +- .../components/copy-cipher-field.directive.ts | 61 +- .../copy-cipher-field.service.spec.ts | 3 +- .../src/services/copy-cipher-field.service.ts | 14 +- .../src/services/password-reprompt.service.ts | 4 +- 54 files changed, 1907 insertions(+), 514 deletions(-) create mode 100644 libs/common/src/vault/types/cipher-like.ts create mode 100644 libs/common/src/vault/utils/cipher-view-like-utils.spec.ts create mode 100644 libs/common/src/vault/utils/cipher-view-like-utils.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 11d13392ce2..7b1262627b6 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -4563,17 +4566,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index aa790d24ede..1eef907821d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -6,12 +6,13 @@ import { combineLatest, map, Observable, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { IconButtonModule, TypographyModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; -import { PopupCipherView } from "../../../views/popup-cipher.view"; +import { PopupCipherViewLike } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -30,7 +31,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** @@ -62,7 +63,9 @@ export class AutofillVaultListItemsComponent { ]).pipe( map( ([hasFilter, ciphers, canAutoFill]) => - !hasFilter && canAutoFill && ciphers.filter((c) => c.type == CipherType.Login).length === 0, + !hasFilter && + canAutoFill && + ciphers.filter((c) => CipherViewLikeUtils.getType(c) == CipherType.Login).length === 0, ), ); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html index 576f6b7def6..567d5277454 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -1,4 +1,4 @@ - + - + - + - + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 75bc984e977..ce61e29e9ef 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -14,8 +14,11 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DialogService, IconButtonModule, @@ -34,12 +37,12 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject(undefined); @Input({ required: true, }) - set cipher(c: CipherView) { + set cipher(c: CipherViewLike) { this._cipher$.next(c); } @@ -109,17 +112,22 @@ export class ItemMoreOptionsComponent { get canViewPassword() { return this.cipher.viewPassword; } + + get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); + } + /** * Determines if the cipher can be autofilled. */ get canAutofill() { return ([CipherType.Login, CipherType.Card, CipherType.Identity] as CipherType[]).includes( - this.cipher.type, + CipherViewLikeUtils.getType(this.cipher), ); } get isLogin() { - return this.cipher.type === CipherType.Login; + return CipherViewLikeUtils.getType(this.cipher) === CipherType.Login; } get favoriteText() { @@ -127,11 +135,13 @@ export class ItemMoreOptionsComponent { } async doAutofill() { - await this.vaultPopupAutofillService.doAutofill(this.cipher); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofill(cipher); } async doAutofillAndSave() { - await this.vaultPopupAutofillService.doAutofillAndSave(this.cipher, false); + const cipher = await this.cipherService.getFullCipherView(this.cipher); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); } async onView() { @@ -140,7 +150,7 @@ export class ItemMoreOptionsComponent { return; } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: this.cipher.id, type: this.cipher.type }, + queryParams: { cipherId: this.cipher.id, type: CipherViewLikeUtils.getType(this.cipher) }, }); } @@ -148,11 +158,14 @@ export class ItemMoreOptionsComponent { * Toggles the favorite status of the cipher and updates it on the server. */ async toggleFavorite() { - this.cipher.favorite = !this.cipher.favorite; + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + cipher.favorite = !cipher.favorite; const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const encryptedCipher = await this.cipherService.encrypt(this.cipher, activeUserId); + + const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", @@ -176,7 +189,7 @@ export class ItemMoreOptionsComponent { return; } - if (this.cipher.login?.hasFido2Credentials) { + if (CipherViewLikeUtils.hasFido2Credentials(this.cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -192,7 +205,7 @@ export class ItemMoreOptionsComponent { queryParams: { clone: true.toString(), cipherId: this.cipher.id, - type: this.cipher.type.toString(), + type: CipherViewLikeUtils.getType(this.cipher).toString(), } as AddEditQueryParams, }); } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 8dca1f9e576..b012b7bf157 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -97,7 +97,8 @@ (click)="primaryActionOnSelect(cipher)" (dblclick)="launchCipher(cipher)" [appA11yTitle]=" - cipherItemTitleKey()(cipher) | i18n: cipher.name : cipher.login.username + cipherItemTitleKey()(cipher) + | i18n: cipher.name : CipherViewLikeUtils.getLogin(cipher)?.username " class="{{ itemHeightClass }}" > @@ -114,11 +115,11 @@ [appA11yTitle]="orgIconTooltip(cipher)" > - {{ cipher.subTitle }} + {{ CipherViewLikeUtils.subtitle(cipher) }} @@ -134,7 +135,7 @@ {{ "fill" | i18n }} - +
diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 21b857b551a..000281c5807 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -9,8 +9,11 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuModule } from "@bitwarden/components"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -20,7 +23,8 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service" templateUrl: "vault-items-v2.component.html", imports: [MenuModule, CommonModule, JslibModule, ScrollingModule], }) -export class VaultItemsV2Component extends BaseVaultItemsComponent { +export class VaultItemsV2Component extends BaseVaultItemsComponent { + protected CipherViewLikeUtils = CipherViewLikeUtils; constructor( searchService: SearchService, private readonly searchBarService: SearchBarService, @@ -37,7 +41,7 @@ export class VaultItemsV2Component extends BaseVaultItemsComponent { }); } - trackByFn(index: number, c: CipherView): string { - return c.id; + trackByFn(index: number, c: C): string { + return c.id!; } } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 6eb6b737899..62ca41a3379 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -40,6 +40,10 @@ import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { BadgeModule, ButtonModule, @@ -124,9 +128,11 @@ const BroadcasterSubscriptionId = "VaultComponent"; }, ], }) -export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { +export class VaultV2Component + implements OnInit, OnDestroy, CopyClickListener +{ @ViewChild(VaultItemsV2Component, { static: true }) - vaultItemsComponent: VaultItemsV2Component | null = null; + vaultItemsComponent: VaultItemsV2Component | null = null; @ViewChild(VaultFilterComponent, { static: true }) vaultFilterComponent: VaultFilterComponent | null = null; @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) @@ -407,14 +413,14 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { this.messagingService.send("minimizeOnCopy"); } - async viewCipher(cipher: CipherView) { - if (cipher.decryptionFailure) { + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipher.id as CipherId], + cipherIds: [c.id as CipherId], }); return; } - + const cipher = await this.cipherService.getFullCipherView(c); if (await this.shouldReprompt(cipher, "view")) { return; } @@ -472,7 +478,8 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { } } - viewCipherMenu(cipher: CipherView) { + async viewCipherMenu(c: CipherViewLike) { + const cipher = await this.cipherService.getFullCipherView(c); const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 197fc0ada0f..47ad93d81e2 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -536,7 +536,7 @@ export class VaultComponent implements OnInit, OnDestroy { const filterFunction = createFilterFunction(filter); if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( this.userId, searchText, [filterFunction], @@ -772,7 +772,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index d79c1c4a8b4..804b533c2de 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -28,6 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DIALOG_DATA, DialogRef, @@ -91,7 +92,7 @@ export interface VaultItemDialogParams { /** * Function to restore a cipher from the trash. */ - restore?: (c: CipherView) => Promise; + restore?: (c: CipherViewLike) => Promise; } export const VaultItemDialogResult = { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 227108ec25d..20b87bfc036 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -4,7 +4,7 @@ type="checkbox" bitCheckbox appStopProp - [disabled]="disabled || cipher.decryptionFailure" + [disabled]="disabled || decryptionFailure" [checked]="checked" (change)="$event ? this.checkedToggled.next() : null" [attr.aria-label]="'vaultItemSelect' | i18n" @@ -30,7 +30,7 @@ > {{ cipher.name }} - +

- {{ cipher.subTitle }} + {{ subtitle }} - - @@ -119,9 +119,9 @@ @@ -151,19 +151,14 @@ {{ "eventLogs" | i18n }} - diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 6078324a059..cb4d8ad70b1 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -6,7 +6,10 @@ import { CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { convertToPermission, @@ -20,11 +23,11 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-cipher-row.component.html", standalone: false, }) -export class VaultCipherRowComponent implements OnInit { +export class VaultCipherRowComponent implements OnInit { protected RowHeightClass = RowHeightClass; @Input() disabled: boolean; - @Input() cipher: CipherView; + @Input() cipher: C; @Input() showOwner: boolean; @Input() showCollections: boolean; @Input() showGroups: boolean; @@ -46,7 +49,7 @@ export class VaultCipherRowComponent implements OnInit { */ @Input() canRestoreCipher: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); @@ -74,33 +77,63 @@ export class VaultCipherRowComponent implements OnInit { } protected get clickAction() { - if (this.cipher.decryptionFailure) { + if (this.decryptionFailure) { return "showFailedToDecrypt"; } + return "view"; } protected get showTotpCopyButton() { - return ( - (this.cipher.login?.hasTotp ?? false) && - (this.cipher.organizationUseTotp || this.showPremiumFeatures) - ); + const login = CipherViewLikeUtils.getLogin(this.cipher); + + const hasTotp = login?.totp ?? false; + + return hasTotp && (this.cipher.organizationUseTotp || this.showPremiumFeatures); } protected get showFixOldAttachments() { return this.cipher.hasOldAttachments && this.cipher.organizationId == null; } + protected get hasAttachments() { + return CipherViewLikeUtils.hasAttachments(this.cipher); + } + protected get showAttachments() { - return this.canEditCipher || this.cipher.attachments?.length > 0; + return this.canEditCipher || this.hasAttachments; + } + + protected get canLaunch() { + return CipherViewLikeUtils.canLaunch(this.cipher); + } + + protected get launchUri() { + return CipherViewLikeUtils.getLaunchUri(this.cipher); + } + + protected get subtitle() { + return CipherViewLikeUtils.subtitle(this.cipher); + } + + protected get isDeleted() { + return CipherViewLikeUtils.isDeleted(this.cipher); + } + + protected get decryptionFailure() { + return CipherViewLikeUtils.decryptionFailure(this.cipher); } protected get showAssignToCollections() { - return this.organizations?.length && this.canAssignCollections && !this.cipher.isDeleted; + return ( + this.organizations?.length && + this.canAssignCollections && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); } protected get showClone() { - return this.cloneable && !this.cipher.isDeleted; + return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher); } protected get showEventLogs() { @@ -108,7 +141,18 @@ export class VaultCipherRowComponent implements OnInit { } protected get isNotDeletedLoginCipher() { - return this.cipher.type === this.CipherType.Login && !this.cipher.isDeleted; + return ( + CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login && + !CipherViewLikeUtils.isDeleted(this.cipher) + ); + } + + protected get hasPasswordToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); + } + + protected get hasUsernameToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); } protected get permissionText() { @@ -154,7 +198,7 @@ export class VaultCipherRowComponent implements OnInit { } protected get showLaunchUri(): boolean { - return this.isNotDeletedLoginCipher && this.cipher.login.canLaunch; + return this.isNotDeletedLoginCipher && this.canLaunch; } protected get disableMenu() { @@ -166,7 +210,7 @@ export class VaultCipherRowComponent implements OnInit { this.showAttachments || this.showClone || this.canEditCipher || - (this.cipher.isDeleted && this.canRestoreCipher) + (CipherViewLikeUtils.isDeleted(this.cipher) && this.canRestoreCipher) ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index 06c78ea0351..5d2b84aa10b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -5,6 +5,7 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { CollectionAdminView, Unassigned, CollectionView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -20,7 +21,7 @@ import { RowHeightClass } from "./vault-items.component"; templateUrl: "vault-collection-row.component.html", standalone: false, }) -export class VaultCollectionRowComponent { +export class VaultCollectionRowComponent { protected RowHeightClass = RowHeightClass; protected Unassigned = "unassigned"; @@ -36,7 +37,7 @@ export class VaultCollectionRowComponent { @Input() groups: GroupView[]; @Input() showPermissionsColumn: boolean; - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); @Input() checked: boolean; @Output() checkedToggled = new EventEmitter(); diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index 272d1585d95..130f86697c7 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -1,17 +1,17 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { VaultItem } from "./vault-item"; -export type VaultItemEvent = - | { type: "viewAttachments"; item: CipherView } +export type VaultItemEvent = + | { type: "viewAttachments"; item: C } | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean } - | { type: "viewEvents"; item: CipherView } + | { type: "viewEvents"; item: C } | { type: "editCollection"; item: CollectionView; readonly: boolean } - | { type: "clone"; item: CipherView } - | { type: "restore"; items: CipherView[] } - | { type: "delete"; items: VaultItem[] } - | { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" } - | { type: "moveToFolder"; items: CipherView[] } - | { type: "assignToCollections"; items: CipherView[] }; + | { type: "clone"; item: C } + | { type: "restore"; items: C[] } + | { type: "delete"; items: VaultItem[] } + | { type: "copyField"; item: C; field: "username" | "password" | "totp" } + | { type: "moveToFolder"; items: C[] } + | { type: "assignToCollections"; items: C[] }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/apps/web/src/app/vault/components/vault-items/vault-item.ts index 6ac198392ad..bccb84fb0bf 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item.ts @@ -1,7 +1,7 @@ import { CollectionView } from "@bitwarden/admin-console/common"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -export interface VaultItem { +export interface VaultItem { collection?: CollectionView; - cipher?: CipherView; + cipher?: C; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 18dfa73ac5a..e82b03a8815 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -6,8 +6,11 @@ import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -32,7 +35,7 @@ type ItemPermission = CollectionPermission | "NoAccess"; templateUrl: "vault-items.component.html", standalone: false, }) -export class VaultItemsComponent { +export class VaultItemsComponent { protected RowHeight = RowHeight; @Input() disabled: boolean; @@ -56,11 +59,11 @@ export class VaultItemsComponent { @Input() addAccessToggle: boolean; @Input() activeCollection: CollectionView | undefined; - private _ciphers?: CipherView[] = []; - @Input() get ciphers(): CipherView[] { + private _ciphers?: C[] = []; + @Input() get ciphers(): C[] { return this._ciphers; } - set ciphers(value: CipherView[] | undefined) { + set ciphers(value: C[] | undefined) { this._ciphers = value ?? []; this.refreshItems(); } @@ -74,11 +77,11 @@ export class VaultItemsComponent { this.refreshItems(); } - @Output() onEvent = new EventEmitter(); + @Output() onEvent = new EventEmitter>(); - protected editableItems: VaultItem[] = []; - protected dataSource = new TableDataSource(); - protected selection = new SelectionModel(true, [], true); + protected editableItems: VaultItem[] = []; + protected dataSource = new TableDataSource>(); + protected selection = new SelectionModel>(true, [], true); protected canDeleteSelected$: Observable; protected canRestoreSelected$: Observable; protected disableMenu$: Observable; @@ -233,7 +236,7 @@ export class VaultItemsComponent { : this.selection.select(...this.editableItems.slice(0, MaxSelectionCount)); } - protected event(event: VaultItemEvent) { + protected event(event: VaultItemEvent) { this.onEvent.emit(event); } @@ -263,7 +266,7 @@ export class VaultItemsComponent { } // TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead - protected canClone(vaultItem: VaultItem) { + protected canClone(vaultItem: VaultItem) { if (vaultItem.cipher.organizationId == null) { return true; } @@ -287,7 +290,7 @@ export class VaultItemsComponent { return false; } - protected canEditCipher(cipher: CipherView) { + protected canEditCipher(cipher: C) { if (cipher.organizationId == null) { return true; } @@ -296,17 +299,17 @@ export class VaultItemsComponent { return (organization.canEditAllCiphers && this.viewingOrgVault) || cipher.edit; } - protected canAssignCollections(cipher: CipherView) { + protected canAssignCollections(cipher: C) { const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId); const editableCollections = this.allCollections.filter((c) => !c.readOnly); return ( (organization?.canEditAllCiphers && this.viewingOrgVault) || - (cipher.canAssignToCollections && editableCollections.length > 0) + (CipherViewLikeUtils.canAssignToCollections(cipher) && editableCollections.length > 0) ); } - protected canManageCollection(cipher: CipherView) { + protected canManageCollection(cipher: C) { // If the cipher is not part of an organization (personal item), user can manage it if (cipher.organizationId == null) { return true; @@ -338,9 +341,11 @@ export class VaultItemsComponent { } private refreshItems() { - const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); - const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher })); - const items: VaultItem[] = [].concat(collections).concat(ciphers); + const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); + const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ + cipher, + })); + const items: VaultItem[] = [].concat(collections).concat(ciphers); // All ciphers are selectable, collections only if they can be edited or deleted this.editableItems = items.filter( @@ -419,7 +424,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. */ - protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { // Collections before ciphers const collectionCompare = this.prioritizeCollections(a, b, direction); if (collectionCompare !== 0) { @@ -432,7 +437,7 @@ export class VaultItemsComponent { /** * Sorts VaultItems based on group names */ - protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { if ( !(a.collection instanceof CollectionAdminView) && !(b.collection instanceof CollectionAdminView) @@ -473,8 +478,8 @@ export class VaultItemsComponent { * Sorts VaultItems based on their permissions, with higher permissions taking precedence. * If permissions are equal, it falls back to sorting by name. */ - protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { - const getPermissionPriority = (item: VaultItem): number => { + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { const permission = item.collection ? this.getCollectionPermission(item.collection) : this.getCipherPermission(item.cipher); @@ -508,8 +513,8 @@ export class VaultItemsComponent { return this.compareNames(a, b); }; - private compareNames(a: VaultItem, b: VaultItem): number { - const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; return getName(a)?.localeCompare(getName(b)) ?? -1; } @@ -517,7 +522,11 @@ export class VaultItemsComponent { * Sorts VaultItems by prioritizing collections over ciphers. * Collections are always placed before ciphers, regardless of the sorting direction. */ - private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + private prioritizeCollections( + a: VaultItem, + b: VaultItem, + direction: SortDirection, + ): number { if (a.collection && !b.collection) { return direction === "asc" ? -1 : 1; } @@ -561,7 +570,7 @@ export class VaultItemsComponent { return "NoAccess"; } - private getCipherPermission(cipher: CipherView): ItemPermission { + private getCipherPermission(cipher: C): ItemPermission { if (!cipher.organizationId || cipher.collectionIds.length === 0) { return CollectionPermission.Manage; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index e65d423a57b..785c07fb634 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -36,6 +36,7 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LayoutComponent } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -158,7 +159,7 @@ export default { argTypes: { onEvent: { action: "onEvent" } }, } as Meta; -type Story = StoryObj; +type Story = StoryObj>; export const Individual: Story = { args: { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 2154ecff1b7..93189f2bf1c 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -85,7 +85,7 @@ describe("vault filter service", () => { policyService.policyAppliesToUser$ .calledWith(PolicyType.SingleOrg, mockUserId) .mockReturnValue(singleOrgPolicy); - cipherService.cipherViews$.mockReturnValue(cipherViews); + cipherService.cipherListViews$.mockReturnValue(cipherViews); vaultFilterService = new VaultFilterService( organizationService, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index f326034e806..1fe618c6c4e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -38,6 +38,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { CipherTypeFilter, @@ -85,7 +86,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { switchMap((userId) => combineLatest([ this.folderService.folderViews$(userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this._organizationFilter, ]), ), @@ -280,7 +281,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected async filterFolders( storedFolders: FolderView[], - ciphers: CipherView[], + ciphers: CipherView[] | CipherListView[], org?: Organization, ): Promise { // If no org or "My Vault" is selected, show all folders diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts index 3082d7cb809..00c540f6029 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.spec.ts @@ -221,7 +221,7 @@ function createCipher(options: Partial = {}) { cipher.favorite = options.favorite ?? false; cipher.deletedDate = options.deletedDate; - cipher.type = options.type; + cipher.type = options.type ?? CipherType.Login; cipher.folderId = options.folderId; cipher.collectionIds = options.collectionIds; cipher.organizationId = options.organizationId; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index a39918df4a7..1ed2e481fb8 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -1,40 +1,46 @@ import { Unassigned } from "@bitwarden/admin-console/common"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { All, RoutedVaultFilterModel } from "./routed-vault-filter.model"; -export type FilterFunction = (cipher: CipherView) => boolean; +export type FilterFunction = (cipher: CipherViewLike) => boolean; export function createFilterFunction(filter: RoutedVaultFilterModel): FilterFunction { return (cipher) => { + const type = CipherViewLikeUtils.getType(cipher); + const isDeleted = CipherViewLikeUtils.isDeleted(cipher); + if (filter.type === "favorites" && !cipher.favorite) { return false; } - if (filter.type === "card" && cipher.type !== CipherType.Card) { + if (filter.type === "card" && type !== CipherType.Card) { return false; } - if (filter.type === "identity" && cipher.type !== CipherType.Identity) { + if (filter.type === "identity" && type !== CipherType.Identity) { return false; } - if (filter.type === "login" && cipher.type !== CipherType.Login) { + if (filter.type === "login" && type !== CipherType.Login) { return false; } - if (filter.type === "note" && cipher.type !== CipherType.SecureNote) { + if (filter.type === "note" && type !== CipherType.SecureNote) { return false; } - if (filter.type === "sshKey" && cipher.type !== CipherType.SshKey) { + if (filter.type === "sshKey" && type !== CipherType.SshKey) { return false; } - if (filter.type === "trash" && !cipher.isDeleted) { + if (filter.type === "trash" && !isDeleted) { return false; } // Hide trash unless explicitly selected - if (filter.type !== "trash" && cipher.isDeleted) { + if (filter.type !== "trash" && isDeleted) { return false; } // No folder - if (filter.folderId === Unassigned && cipher.folderId !== null) { + if (filter.folderId === Unassigned && cipher.folderId != null) { return false; } // Folder diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index b4eda51435f..8dc442abe2e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -24,7 +24,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LinkModule } from "@bitwarden/components"; import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module"; @@ -44,7 +44,7 @@ import { VaultOnboardingService, VaultOnboardingTasks } from "./services/vault-o templateUrl: "vault-onboarding.component.html", }) export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { - @Input() ciphers: CipherView[]; + @Input() ciphers: CipherViewLike[]; @Input() orgs: Organization[]; @Output() onAddCipher = new EventEmitter(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 380e0280b5a..c8c2f681bb4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -67,8 +67,13 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/components"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult, @@ -149,7 +154,7 @@ const SearchTextDebounceInterval = 200; DefaultCipherFormConfigService, ], }) -export class VaultComponent implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { @ViewChild("vaultFilter", { static: true }) filterComponent: VaultFilterComponent; trashCleanupWarning: string = null; @@ -165,7 +170,7 @@ export class VaultComponent implements OnInit, OnDestroy { protected canAccessPremium: boolean; protected allCollections: CollectionView[]; protected allOrganizations: Organization[] = []; - protected ciphers: CipherView[]; + protected ciphers: C[]; protected collections: CollectionView[]; protected isEmpty: boolean; protected selectedCollection: TreeNode | undefined; @@ -350,11 +355,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); + const _ciphers = this.cipherService + .cipherListViews$(activeUserId) + .pipe(filter((c) => c !== null)); + /** * This observable filters the ciphers based on the active user ID and the restricted item types. */ const allowedCiphers$ = combineLatest([ - this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)), + _ciphers, this.restrictedItemTypesService.restricted$, ]).pipe( map(([ciphers, restrictedTypes]) => @@ -374,15 +383,15 @@ export class VaultComponent implements OnInit, OnDestroy { const allCiphers = [...failedCiphers, ...ciphers]; if (await this.searchService.isSearchable(activeUserId, searchText)) { - return await this.searchService.searchCiphers( + return await this.searchService.searchCiphers( activeUserId, searchText, [filterFunction], - allCiphers, + allCiphers as C[], ); } - return allCiphers.filter(filterFunction); + return ciphers.filter(filterFunction) as C[]; }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -566,7 +575,7 @@ export class VaultComponent implements OnInit, OnDestroy { this.vaultFilterService.clearOrganizationFilter(); } - async onVaultItemsEvent(event: VaultItemEvent) { + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { switch (event.type) { @@ -654,7 +663,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipher * @returns */ - async editCipherAttachments(cipher: CipherView) { + async editCipherAttachments(cipher: C) { if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { await this.go({ cipherId: null, itemId: null }); return; @@ -761,7 +770,7 @@ export class VaultComponent implements OnInit, OnDestroy { await this.openVaultItemDialog("form", cipherFormConfig); } - async editCipher(cipher: CipherView, cloneMode?: boolean) { + async editCipher(cipher: CipherView | CipherListView, cloneMode?: boolean) { return this.editCipherId(cipher?.id, cloneMode); } @@ -929,7 +938,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkAssignToCollections(ciphers: CipherView[]) { + async bulkAssignToCollections(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -955,9 +964,28 @@ export class VaultComponent implements OnInit, OnDestroy { ); } + let ciphersToAssign: CipherView[]; + + // Convert `CipherListView` to `CipherView` if necessary + if (ciphers.some(CipherViewLikeUtils.isCipherListView)) { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + ciphersToAssign = await firstValueFrom( + this.cipherService + .cipherViews$(userId) + .pipe( + map( + (cipherViews) => + cipherViews.filter((c) => ciphers.some((cc) => cc.id === c.id)) as CipherView[], + ), + ), + ); + } else { + ciphersToAssign = ciphers as CipherView[]; + } + const dialog = AssignCollectionsWebComponent.open(this.dialogService, { data: { - ciphers, + ciphers: ciphersToAssign, organizationId: orgId as OrganizationId, availableCollections, activeCollection: this.activeFilter?.selectedCollectionNode?.node, @@ -970,8 +998,8 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { + async cloneCipher(cipher: CipherView | CipherListView) { + if (CipherViewLikeUtils.hasFido2Credentials(cipher)) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "passkeyNotCopied" }, content: { key: "passkeyNotCopiedAlert" }, @@ -986,8 +1014,8 @@ export class VaultComponent implements OnInit, OnDestroy { await this.editCipher(cipher, true); } - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { + restore = async (c: C): Promise => { + if (!CipherViewLikeUtils.isDeleted(c)) { return; } @@ -1014,7 +1042,7 @@ export class VaultComponent implements OnInit, OnDestroy { } }; - async bulkRestore(ciphers: CipherView[]) { + async bulkRestore(ciphers: C[]) { if (ciphers.some((c) => !c.edit)) { this.showMissingPermissionsError(); return; @@ -1044,8 +1072,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.refresh(); } - private async handleDeleteEvent(items: VaultItem[]) { - const ciphers = items.filter((i) => i.collection === undefined).map((i) => i.cipher); + private async handleDeleteEvent(items: VaultItem[]) { + const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher); const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection); if (ciphers.length === 1 && collections.length === 0) { await this.deleteCipher(ciphers[0]); @@ -1062,7 +1090,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async deleteCipher(c: CipherView): Promise { + async deleteCipher(c: C): Promise { if (!(await this.repromptCipher([c]))) { return; } @@ -1072,7 +1100,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - const permanent = c.isDeleted; + const permanent = CipherViewLikeUtils.isDeleted(c); const confirmed = await this.dialogService.openSimpleDialog({ title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, @@ -1099,11 +1127,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkDelete( - ciphers: CipherView[], - collections: CollectionView[], - organizations: Organization[], - ) { + async bulkDelete(ciphers: C[], collections: CollectionView[], organizations: Organization[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1142,7 +1166,7 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async bulkMove(ciphers: CipherView[]) { + async bulkMove(ciphers: C[]) { if (!(await this.repromptCipher(ciphers))) { return; } @@ -1167,22 +1191,32 @@ export class VaultComponent implements OnInit, OnDestroy { } } - async copy(cipher: CipherView, field: "username" | "password" | "totp") { + async copy(cipher: C, field: "username" | "password" | "totp") { let aType; let value; let typeI18nKey; + const login = CipherViewLikeUtils.getLogin(cipher); + + if (!login) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + } + if (field === "username") { aType = "Username"; - value = cipher.login.username; + value = login.username; typeI18nKey = "username"; } else if (field === "password") { aType = "Password"; - value = cipher.login.password; + value = await this.getPasswordFromCipherViewLike(cipher); typeI18nKey = "password"; } else if (field === "totp") { aType = "TOTP"; - const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); + const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp)); value = totpResponse.code; typeI18nKey = "verificationCodeTotp"; } else { @@ -1228,7 +1262,7 @@ export class VaultComponent implements OnInit, OnDestroy { : this.cipherService.softDeleteWithServer(id, userId); } - protected async repromptCipher(ciphers: CipherView[]) { + protected async repromptCipher(ciphers: C[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); @@ -1264,6 +1298,21 @@ export class VaultComponent implements OnInit, OnDestroy { message: this.i18nService.t("missingPermissions"), }); } + + /** + * Returns the password for a `CipherViewLike` object. + * `CipherListView` does not contain the password, the full `CipherView` needs to be fetched. + */ + private async getPasswordFromCipherViewLike(cipher: C): Promise { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + return Promise.resolve(cipher.login?.password); + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const _cipher = await this.cipherService.get(cipher.id, activeUserId); + const cipherView = await this.cipherService.decrypt(_cipher, activeUserId); + return cipherView.login?.password; + } } /** diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 475fb004033..4c4a97e6404 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -864,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index fd178db23b6..0718b6fc76c 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -13,7 +13,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Component({ selector: "app-vault-icon", @@ -25,7 +25,7 @@ export class IconComponent { /** * The cipher to display the icon for. */ - cipher = input.required(); + cipher = input.required(); imageLoaded = signal(false); diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cf017899774..75ca5608208 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -21,20 +21,23 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; @Directive() -export class VaultItemsComponent implements OnInit, OnDestroy { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; - @Output() onCipherClicked = new EventEmitter(); - @Output() onCipherRightClicked = new EventEmitter(); + @Output() onCipherClicked = new EventEmitter(); + @Output() onCipherRightClicked = new EventEmitter(); @Output() onAddCipher = new EventEmitter(); @Output() onAddCipherOptions = new EventEmitter(); loaded = false; - ciphers: CipherView[] = []; + ciphers: C[] = []; deleted = false; organization: Organization; CipherType = CipherType; @@ -55,7 +58,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected searchPending = false; /** Construct filters as an observable so it can be appended to the cipher stream. */ - private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null); + private _filter$ = new BehaviorSubject<(cipher: C) => boolean | null>(null); private destroy$ = new Subject(); private isSearchable: boolean = false; private _searchText$ = new BehaviorSubject(""); @@ -71,7 +74,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return this._filter$.value; } - set filter(value: (cipher: CipherView) => boolean | null) { + set filter(value: (cipher: C) => boolean | null) { this._filter$.next(value); } @@ -102,13 +105,13 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async load(filter: (cipher: C) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); this.loaded = true; } - async reload(filter: (cipher: CipherView) => boolean = null, deleted = false) { + async reload(filter: (cipher: C) => boolean = null, deleted = false) { this.loaded = false; await this.load(filter, deleted); } @@ -117,15 +120,15 @@ export class VaultItemsComponent implements OnInit, OnDestroy { await this.reload(this.filter, this.deleted); } - async applyFilter(filter: (cipher: CipherView) => boolean = null) { + async applyFilter(filter: (cipher: C) => boolean = null) { this.filter = filter; } - selectCipher(cipher: CipherView) { + selectCipher(cipher: C) { this.onCipherClicked.emit(cipher); } - rightClickCipher(cipher: CipherView) { + rightClickCipher(cipher: C) { this.onCipherRightClicked.emit(cipher); } @@ -141,7 +144,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy { return !this.searchPending && this.isSearchable; } - protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; + protected deletedFilter: (cipher: C) => boolean = (c) => + CipherViewLikeUtils.isDeleted(c) === this.deleted; /** * Creates stream of dependencies that results in the list of ciphers to display @@ -156,7 +160,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { .pipe( switchMap((userId) => combineLatest([ - this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), + this.cipherService.cipherListViews$(userId).pipe(filter((ciphers) => ciphers != null)), this.cipherService.failedToDecryptCiphers$(userId), this._searchText$, this._filter$, @@ -165,12 +169,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy { ]), ), switchMap(([indexedCiphers, failedCiphers, searchText, filter, userId, restricted]) => { - let allCiphers = indexedCiphers ?? []; + let allCiphers = (indexedCiphers ?? []) as C[]; const _failedCiphers = failedCiphers ?? []; - allCiphers = [..._failedCiphers, ...allCiphers]; + allCiphers = [..._failedCiphers, ...allCiphers] as C[]; - const restrictedTypeFilter = (cipher: CipherView) => + const restrictedTypeFilter = (cipher: CipherViewLike) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted); return this.searchService.searchCiphers( diff --git a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts index 3122bdac2e0..8302ff541aa 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -25,7 +25,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService { nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return combineLatest([ this.getNudgeStatus$(nudgeType, userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherListViews$(userId), this.organizationService.organizations$(userId), this.collectionService.decryptedCollections$, ]).pipe( diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 8f63c31d87a..fa383dd28da 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherStatus } from "./cipher-status.model"; -export type VaultFilterFunction = (cipher: CipherView) => boolean; +export type VaultFilterFunction = (cipher: CipherViewLike) => boolean; export class VaultFilter { cipherType?: CipherType; @@ -44,10 +47,10 @@ export class VaultFilter { cipherPassesFilter = cipher.favorite; } if (this.status === "trash" && cipherPassesFilter) { - cipherPassesFilter = cipher.isDeleted; + cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher); } if (this.cipherType != null && cipherPassesFilter) { - cipherPassesFilter = cipher.type === this.cipherType; + cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } if (this.selectedFolder && this.selectedFolderId == null && cipherPassesFilter) { cipherPassesFilter = cipher.folderId == null; @@ -68,7 +71,7 @@ export class VaultFilter { cipherPassesFilter = cipher.organizationId === this.selectedOrganizationId; } if (this.myVaultOnly && cipherPassesFilter) { - cipherPassesFilter = cipher.organizationId === null; + cipherPassesFilter = cipher.organizationId == null; } return cipherPassesFilter; }; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index da14f7dada3..1af2ab1f0a9 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -53,6 +53,7 @@ export enum FeatureFlag { PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", + PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", CipherKeyEncryption = "cipher-key-encryption", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", @@ -100,6 +101,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, + [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, /* Auth */ diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 9f5c173826e..d1d686a66af 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -20,6 +21,7 @@ import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export type EncryptionContext = { cipher: Cipher; @@ -29,6 +31,7 @@ export type EncryptionContext = { export abstract class CipherService implements UserKeyRotationDataProvider { abstract cipherViews$(userId: UserId): Observable; + abstract cipherListViews$(userId: UserId): Observable; abstract ciphers$(userId: UserId): Observable>; abstract localData$(userId: UserId): Observable>; /** @@ -65,12 +68,12 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract filterCiphersForUrl( - ciphers: CipherView[], + abstract filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, - ): Promise; + ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** * Gets ciphers belonging to the specified organization that the user has explicit collection level access to. @@ -198,9 +201,9 @@ export abstract class CipherService implements UserKeyRotationDataProvider; - abstract sortCiphersByLastUsed(a: CipherView, b: CipherView): number; - abstract sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number; - abstract getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number; + abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number; + abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number; + abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number; abstract softDelete(id: string | string[], userId: UserId): Promise; abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; @@ -251,4 +254,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + + /** + * Decrypts the full `CipherView` for a given `CipherViewLike`. + * When a `CipherView` instance is passed, it returns it as is. + */ + abstract getFullCipherView(c: CipherViewLike): Promise; } diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index c981aa748a4..ed8bb2c3baf 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -5,6 +5,7 @@ import { Observable } from "rxjs"; import { SendView } from "../../tools/send/models/view/send.view"; import { IndexedEntityId, UserId } from "../../types/guid"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; export abstract class SearchService { indexedEntityId$: (userId: UserId) => Observable; @@ -16,12 +17,16 @@ export abstract class SearchService { ciphersToIndex: CipherView[], indexedEntityGuid?: string, ) => Promise; - searchCiphers: ( + searchCiphers: ( userId: UserId, query: string, - filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], - ciphers?: CipherView[], - ) => Promise; - searchCiphersBasic: (ciphers: CipherView[], query: string, deleted?: boolean) => CipherView[]; + filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[], + ciphers?: C[], + ) => Promise; + searchCiphersBasic: ( + ciphers: C[], + query: string, + deleted?: boolean, + ) => C[]; searchSends: (sends: SendView[], query: string) => SendView[]; } diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index b7456e1ae96..a081511d792 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -1,6 +1,6 @@ import { Utils } from "../../platform/misc/utils"; import { CipherType } from "../enums/cipher-type"; -import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export interface CipherIconDetails { imageEnabled: boolean; @@ -14,7 +14,7 @@ export interface CipherIconDetails { export function buildCipherIcon( iconsServerUrl: string | null, - cipher: CipherView, + cipher: CipherViewLike, showFavicon: boolean, ): CipherIconDetails { let icon: string = "bwi-globe"; @@ -36,12 +36,16 @@ export function buildCipherIcon( showFavicon = false; } - switch (cipher.type) { + const cipherType = CipherViewLikeUtils.getType(cipher); + const uri = CipherViewLikeUtils.uri(cipher); + const card = CipherViewLikeUtils.getCard(cipher); + + switch (cipherType) { case CipherType.Login: icon = "bwi-globe"; - if (cipher.login.uri) { - let hostnameUri = cipher.login.uri; + if (uri) { + let hostnameUri = uri; let isWebsite = false; if (hostnameUri.indexOf("androidapp://") === 0) { @@ -84,8 +88,8 @@ export function buildCipherIcon( break; case CipherType.Card: icon = "bwi-credit-card"; - if (showFavicon && cipher.card.brand in cardIcons) { - icon = `credit-card-icon ${cardIcons[cipher.card.brand]}`; + if (showFavicon && card?.brand && card.brand in cardIcons) { + icon = `credit-card-icon ${cardIcons[card.brand]}`; } break; case CipherType.Identity: diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts index ab3676930b5..2933e94c302 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -8,13 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { CollectionId } from "@bitwarden/common/types/guid"; import { getUserId } from "../../auth/services/account.service"; -import { Cipher } from "../models/domain/cipher"; -import { CipherView } from "../models/view/cipher.view"; - -/** - * Represents either a cipher or a cipher view. - */ -type CipherLike = Cipher | CipherView; +import { CipherLike } from "../types/cipher-like"; /** * Service for managing user cipher authorization. @@ -95,7 +89,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.delete; + return !!cipher.permissions?.delete; }), ); } @@ -118,7 +112,7 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer } } - return cipher.permissions.restore; + return !!cipher.permissions?.restore; }), ); } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index c967f2614c8..8bef5289a95 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -71,6 +71,7 @@ import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; import { ADD_EDIT_CIPHER_INFO_KEY, @@ -123,6 +124,43 @@ export class CipherService implements CipherServiceAbstraction { return this.encryptedCiphersState(userId).state$.pipe(map((ciphers) => ciphers ?? {})); } + /** + * Observable that emits an array of decrypted ciphers for given userId. + * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. + * + * This uses the SDK for decryption, when the `PM22134SdkCipherListView` feature flag is disabled the full `cipherViews$` observable will be emitted. + * Usage of the {@link CipherViewLike} type is recommended to ensure both `CipherView` and `CipherListView` are supported. + */ + cipherListViews$ = perUserCache$((userId: UserId) => { + return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe( + switchMap((useSdk) => { + if (!useSdk) { + return this.cipherViews$(userId); + } + + return combineLatest([ + this.encryptedCiphersState(userId).state$, + this.localData$(userId), + this.keyService.cipherDecryptionKeys$(userId, true), + ]).pipe( + filter(([cipherDataState, _, keys]) => cipherDataState != null && keys != null), + map(([cipherDataState, localData]) => + Object.values(cipherDataState).map( + (cipherData) => new Cipher(cipherData, localData?.[cipherData.id as CipherId]), + ), + ), + switchMap(async (ciphers) => { + // TODO: remove this once failed decrypted ciphers are handled in the SDK + await this.setFailedDecryptedCiphers([], userId); + return this.cipherEncryptionService + .decryptMany(ciphers, userId) + .then((ciphers) => ciphers.sort(this.getLocaleSortingFunction())); + }), + ); + }), + ); + }); + /** * Observable that emits an array of decrypted ciphers for the active user. * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. @@ -543,18 +581,23 @@ export class CipherService implements CipherServiceAbstraction { filter((c) => c != null), switchMap( async (ciphers) => - await this.filterCiphersForUrl(ciphers, url, includeOtherTypes, defaultMatch), + await this.filterCiphersForUrl( + ciphers, + url, + includeOtherTypes, + defaultMatch, + ), ), ), ); } - async filterCiphersForUrl( - ciphers: CipherView[], + async filterCiphersForUrl( + ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, - ): Promise { + ): Promise { if (url == null && includeOtherTypes == null) { return []; } @@ -565,22 +608,20 @@ export class CipherService implements CipherServiceAbstraction { defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$); return ciphers.filter((cipher) => { - const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; + const type = CipherViewLikeUtils.getType(cipher); + const login = CipherViewLikeUtils.getLogin(cipher); + const cipherIsLogin = login !== null; - if (cipher.deletedDate !== null) { + if (CipherViewLikeUtils.isDeleted(cipher)) { return false; } - if ( - Array.isArray(includeOtherTypes) && - includeOtherTypes.includes(cipher.type) && - !cipherIsLogin - ) { + if (Array.isArray(includeOtherTypes) && includeOtherTypes.includes(type) && !cipherIsLogin) { return true; } if (cipherIsLogin) { - return cipher.login.matchesUri(url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); } return false; @@ -1173,7 +1214,7 @@ export class CipherService implements CipherServiceAbstraction { return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId); } - sortCiphersByLastUsed(a: CipherView, b: CipherView): number { + sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number { const aLastUsed = a.localData && a.localData.lastUsedDate ? (a.localData.lastUsedDate as number) : null; const bLastUsed = @@ -1197,7 +1238,7 @@ export class CipherService implements CipherServiceAbstraction { return 0; } - sortCiphersByLastUsedThenName(a: CipherView, b: CipherView): number { + sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number { const result = this.sortCiphersByLastUsed(a, b); if (result !== 0) { return result; @@ -1206,7 +1247,7 @@ export class CipherService implements CipherServiceAbstraction { return this.getLocaleSortingFunction()(a, b); } - getLocaleSortingFunction(): (a: CipherView, b: CipherView) => number { + getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number { return (a, b) => { let aName = a.name; let bName = b.name; @@ -1225,16 +1266,22 @@ export class CipherService implements CipherServiceAbstraction { ? this.i18nService.collator.compare(aName, bName) : aName.localeCompare(bName); - if (result !== 0 || a.type !== CipherType.Login || b.type !== CipherType.Login) { + const aType = CipherViewLikeUtils.getType(a); + const bType = CipherViewLikeUtils.getType(b); + + if (result !== 0 || aType !== CipherType.Login || bType !== CipherType.Login) { return result; } - if (a.login.username != null) { - aName += a.login.username; + const aLogin = CipherViewLikeUtils.getLogin(a); + const bLogin = CipherViewLikeUtils.getLogin(b); + + if (aLogin.username != null) { + aName += aLogin.username; } - if (b.login.username != null) { - bName += b.login.username; + if (bLogin.username != null) { + bName += bLogin.username; } return this.i18nService.collator @@ -1902,4 +1949,17 @@ export class CipherService implements CipherServiceAbstraction { return decryptedViews.sort(this.getLocaleSortingFunction()); } + + /** Fetches the full `CipherView` when a `CipherListView` is passed. */ + async getFullCipherView(c: CipherViewLike): Promise { + if (CipherViewLikeUtils.isCipherListView(c)) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const cipher = await this.get(c.id!, activeUserId); + return this.decrypt(cipher, activeUserId); + } + + return Promise.resolve(c); + } } diff --git a/libs/common/src/vault/services/restricted-item-types.service.ts b/libs/common/src/vault/services/restricted-item-types.service.ts index 6b848e6626b..8ccc94d365c 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.ts @@ -9,17 +9,15 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { Cipher } from "../models/domain/cipher"; +import { CipherLike } from "../types/cipher-like"; +import { CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type RestrictedCipherType = { cipherType: CipherType; allowViewOrgIds: string[]; }; -type CipherLike = Cipher | CipherView; - export class RestrictedItemTypesService { /** * Emits an array of RestrictedCipherType objects: @@ -94,7 +92,9 @@ export class RestrictedItemTypesService { * - Otherwise → restricted */ isCipherRestricted(cipher: CipherLike, restrictedTypes: RestrictedCipherType[]): boolean { - const restriction = restrictedTypes.find((r) => r.cipherType === cipher.type); + const restriction = restrictedTypes.find( + (r) => r.cipherType === CipherViewLikeUtils.getType(cipher), + ); // If cipher type is not restricted by any organization, allow it if (!restriction) { diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index 8e54fa695bd..614fba4a7ca 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -19,6 +19,7 @@ import { SearchService as SearchServiceAbstraction } from "../abstractions/searc import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherView } from "../models/view/cipher.view"; +import { CipherViewLike, CipherViewLikeUtils } from "../utils/cipher-view-like-utils"; export type SerializedLunrIndex = { version: string; @@ -197,13 +198,13 @@ export class SearchService implements SearchServiceAbstraction { ]); } - async searchCiphers( + async searchCiphers( userId: UserId, query: string, - filter: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[] = null, - ciphers: CipherView[], - ): Promise { - const results: CipherView[] = []; + filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null, + ciphers: C[], + ): Promise { + const results: C[] = []; if (query != null) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); } @@ -218,7 +219,7 @@ export class SearchService implements SearchServiceAbstraction { if (filter != null && Array.isArray(filter) && filter.length > 0) { ciphers = ciphers.filter((c) => filter.every((f) => f == null || f(c))); } else if (filter != null) { - ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); + ciphers = ciphers.filter(filter as (cipher: C) => boolean); } if (!(await this.isSearchable(userId, query))) { @@ -238,7 +239,7 @@ export class SearchService implements SearchServiceAbstraction { return this.searchCiphersBasic(ciphers, query); } - const ciphersMap = new Map(); + const ciphersMap = new Map(); ciphers.forEach((c) => ciphersMap.set(c.id, c)); let searchResults: lunr.Index.Result[] = null; @@ -272,10 +273,10 @@ export class SearchService implements SearchServiceAbstraction { return results; } - searchCiphersBasic(ciphers: CipherView[], query: string, deleted = false) { + searchCiphersBasic(ciphers: C[], query: string, deleted = false) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); return ciphers.filter((c) => { - if (deleted !== c.isDeleted) { + if (deleted !== CipherViewLikeUtils.isDeleted(c)) { return false; } if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { @@ -284,13 +285,17 @@ export class SearchService implements SearchServiceAbstraction { if (query.length >= 8 && c.id.startsWith(query)) { return true; } - if (c.subTitle != null && c.subTitle.toLowerCase().indexOf(query) > -1) { + const subtitle = CipherViewLikeUtils.subtitle(c); + if (subtitle != null && subtitle.toLowerCase().indexOf(query) > -1) { return true; } + + const login = CipherViewLikeUtils.getLogin(c); + if ( - c.login && - c.login.hasUris && - c.login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) + login && + login.uris.length && + login.uris.some((loginUri) => loginUri?.uri?.toLowerCase().indexOf(query) > -1) ) { return true; } diff --git a/libs/common/src/vault/types/cipher-like.ts b/libs/common/src/vault/types/cipher-like.ts new file mode 100644 index 00000000000..61fb4ef86a5 --- /dev/null +++ b/libs/common/src/vault/types/cipher-like.ts @@ -0,0 +1,9 @@ +import { Cipher } from "../models/domain/cipher"; +import { CipherViewLike } from "../utils/cipher-view-like-utils"; + +/** + * Represents either a Cipher, CipherView or CipherListView. + * + * {@link CipherViewLikeUtils} provides logic to perform operations on each type. + */ +export type CipherLike = Cipher | CipherViewLike; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts new file mode 100644 index 00000000000..f302340ef9e --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -0,0 +1,624 @@ +import { CipherListView } from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Attachment } from "../models/domain/attachment"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { Fido2CredentialView } from "../models/view/fido2-credential.view"; +import { IdentityView } from "../models/view/identity.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +import { CipherViewLikeUtils } from "./cipher-view-like-utils"; + +describe("CipherViewLikeUtils", () => { + const createCipherView = (type: CipherType = CipherType.Login): CipherView => { + const cipherView = new CipherView(); + // Always set a type to avoid issues within `CipherViewLikeUtils` + cipherView.type = type; + + return cipherView; + }; + + describe("isCipherListView", () => { + it("returns true when the cipher is a CipherListView", () => { + const cipherListViewLogin = { + type: { + login: {}, + }, + } as CipherListView; + const cipherListViewSshKey = { + type: "sshKey", + } as CipherListView; + + expect(CipherViewLikeUtils.isCipherListView(cipherListViewLogin)).toBe(true); + expect(CipherViewLikeUtils.isCipherListView(cipherListViewSshKey)).toBe(true); + }); + + it("returns false when the cipher is not a CipherListView", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.SecureNote; + + expect(CipherViewLikeUtils.isCipherListView(cipherView)).toBe(false); + }); + }); + + describe("getLogin", () => { + it("returns null when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getLogin({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the login object", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.getLogin(cipherView)).toEqual(cipherView.login); + }); + }); + + describe("CipherListView", () => { + it("returns the login object", () => { + const cipherListView = { + type: { + login: { + username: "testuser", + hasFido2: false, + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLogin(cipherListView)).toEqual( + (cipherListView.type as any).login, + ); + }); + }); + }); + + describe("getCard", () => { + it("returns null when the cipher is not a card", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getCard(cipherView)).toBeNull(); + expect(CipherViewLikeUtils.getCard({ type: "identity" } as CipherListView)).toBeNull(); + }); + + describe("CipherView", () => { + it("returns the card object", () => { + const cipherView = createCipherView(CipherType.Card); + + expect(CipherViewLikeUtils.getCard(cipherView)).toEqual(cipherView.card); + }); + }); + + describe("CipherListView", () => { + it("returns the card object", () => { + const cipherListView = { + type: { + card: { + brand: "Visa", + }, + }, + } as CipherListView; + + expect(CipherViewLikeUtils.getCard(cipherListView)).toEqual( + (cipherListView.type as any).card, + ); + }); + }); + }); + + describe("isDeleted", () => { + it("returns true when the cipher is deleted", () => { + const cipherListView = { deletedDate: "2024-02-02", type: "identity" } as CipherListView; + const cipherView = createCipherView(); + cipherView.deletedDate = new Date(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(true); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(true); + }); + + it("returns false when the cipher is not deleted", () => { + const cipherListView = { deletedDate: undefined, type: "identity" } as CipherListView; + const cipherView = createCipherView(); + + expect(CipherViewLikeUtils.isDeleted(cipherListView)).toBe(false); + expect(CipherViewLikeUtils.isDeleted(cipherView)).toBe(false); + }); + }); + + describe("canAssignToCollections", () => { + describe("CipherView", () => { + let cipherView: CipherView; + + beforeEach(() => { + cipherView = createCipherView(); + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = false; + cipherView.viewPassword = false; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherView.organizationId = "org-id"; + cipherView.edit = true; + cipherView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherView)).toBe(true); + }); + }); + + describe("CipherListView", () => { + let cipherListView: CipherListView; + + beforeEach(() => { + cipherListView = { + organizationId: undefined, + edit: false, + viewPassword: false, + type: { login: {} }, + } as CipherListView; + }); + + it("returns true when the cipher is not assigned to an organization", () => { + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + + it("returns false when the cipher is assigned to an organization and cannot be edited", () => { + cipherListView.organizationId = "org-id"; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(false); + }); + + it("returns true when the cipher is assigned to an organization and can be edited", () => { + cipherListView.organizationId = "org-id"; + cipherListView.edit = true; + cipherListView.viewPassword = true; + + expect(CipherViewLikeUtils.canAssignToCollections(cipherListView)).toBe(true); + }); + }); + }); + + describe("getType", () => { + describe("CipherView", () => { + it("returns the type of the cipher", () => { + const cipherView = createCipherView(); + cipherView.type = CipherType.Login; + + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Login); + + cipherView.type = CipherType.SecureNote; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SecureNote); + + cipherView.type = CipherType.SshKey; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.SshKey); + + cipherView.type = CipherType.Identity; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Identity); + + cipherView.type = CipherType.Card; + expect(CipherViewLikeUtils.getType(cipherView)).toBe(CipherType.Card); + }); + }); + + describe("CipherListView", () => { + it("converts the `CipherViewListType` to `CipherType`", () => { + const cipherListView = { + type: { login: {} }, + } as CipherListView; + + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Login); + + cipherListView.type = { card: { brand: "Visa" } }; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Card); + + cipherListView.type = "sshKey"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SshKey); + + cipherListView.type = "identity"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.Identity); + + cipherListView.type = "secureNote"; + expect(CipherViewLikeUtils.getType(cipherListView)).toBe(CipherType.SecureNote); + }); + }); + }); + + describe("subtitle", () => { + describe("CipherView", () => { + it("returns the subtitle of the cipher", () => { + const cipherView = createCipherView(); + cipherView.login = new LoginView(); + cipherView.login.username = "Test Username"; + + expect(CipherViewLikeUtils.subtitle(cipherView)).toBe("Test Username"); + }); + }); + + describe("CipherListView", () => { + it("returns the subtitle of the cipher", () => { + const cipherListView = { + subtitle: "Test Subtitle", + type: "identity", + } as CipherListView; + + expect(CipherViewLikeUtils.subtitle(cipherListView)).toBe("Test Subtitle"); + }); + }); + }); + + describe("hasAttachments", () => { + describe("CipherView", () => { + it("returns true when the cipher has attachments", () => { + const cipherView = createCipherView(); + cipherView.attachments = [new AttachmentView({ id: "1" } as Attachment)]; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(true); + }); + + it("returns false when the cipher has no attachments", () => { + const cipherView = new CipherView(); + (cipherView.attachments as any) = null; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when there are attachments", () => { + const cipherListView = { attachments: 1, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(true); + }); + + it("returns false when there are no attachments", () => { + const cipherListView = { attachments: 0, type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.hasAttachments(cipherListView)).toBe(false); + }); + }); + }); + + describe("canLaunch", () => { + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + expect(CipherViewLikeUtils.canLaunch({ type: "identity" } as CipherListView)).toBe(false); + }); + + describe("CipherView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [{ uri: "https://example.com" } as LoginUriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.canLaunch(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has URIs that can be launched", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns true when the uri does not have a protocol", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "bitwarden.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(true); + }); + + it("returns false when the login has no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.canLaunch(cipherListView)).toBe(false); + }); + }); + }); + + describe("getLaunchUri", () => { + it("returns undefined when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + expect( + CipherViewLikeUtils.getLaunchUri({ type: "identity" } as CipherListView), + ).toBeUndefined(); + }); + + describe("CipherView", () => { + it("returns the first launch-able URI", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.uris = [ + { uri: "" } as LoginUriView, + { uri: "https://example.com" } as LoginUriView, + { uri: "https://another.com" } as LoginUriView, + ]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBeUndefined(); + }); + + it("appends protocol when there are none", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uriView = new LoginUriView(); + uriView.uri = "bitwarden.com"; + cipherView.login.uris = [uriView]; + + expect(CipherViewLikeUtils.getLaunchUri(cipherView)).toBe("http://bitwarden.com"); + }); + }); + + describe("CipherListView", () => { + it("returns the first launch-able URI", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "" }, { uri: "https://example.com" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBe("https://example.com"); + }); + + it("returns undefined when there are no URIs", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.getLaunchUri(cipherListView)).toBeUndefined(); + }); + }); + }); + + describe("matchesUri", () => { + const emptySet = new Set(); + + it("returns false when the cipher is not a login", () => { + const cipherView = createCipherView(CipherType.SecureNote); + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + false, + ); + }); + + describe("CipherView", () => { + it("returns true when the URI matches", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://example.com"; + cipherView.login.uris = [uri]; + + expect(CipherViewLikeUtils.matchesUri(cipherView, "https://example.com", emptySet)).toBe( + true, + ); + }); + + it("returns false when the URI does not match", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + const uri = new LoginUriView(); + uri.uri = "https://www.bitwarden.com"; + cipherView.login.uris = [uri]; + + expect( + CipherViewLikeUtils.matchesUri(cipherView, "https://www.another.com", emptySet), + ).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the URI matches", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://example.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://example.com", emptySet), + ).toBe(true); + }); + + it("returns false when the URI does not match", () => { + const cipherListView = { + type: { login: { uris: [{ uri: "https://bitwarden.com" }] } }, + } as CipherListView; + + expect( + CipherViewLikeUtils.matchesUri(cipherListView, "https://another.com", emptySet), + ).toBe(false); + }); + }); + }); + + describe("hasCopyableValue", () => { + describe("CipherView", () => { + it("returns true for login fields", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(true); + }); + + it("returns true for card fields", () => { + const cipherView = createCipherView(CipherType.Card); + cipherView.card = { number: "1234-5678-9012-3456", code: "123" } as any; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(true); + }); + + it("returns true for identity fields", () => { + const cipherView = createCipherView(CipherType.Identity); + cipherView.identity = new IdentityView(); + cipherView.identity.email = "example@bitwarden.com"; + cipherView.identity.phone = "123-456-7890"; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "phone")).toBe(true); + }); + + it("returns false when values are not populated", () => { + const cipherView = createCipherView(CipherType.Login); + + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "email")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "securityCode")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherView, "username")).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true for copyable fields in a login cipher", () => { + const cipherListView = { + type: { login: { username: "testuser" } }, + copyableFields: ["LoginUsername", "LoginPassword"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(true); + }); + + it("returns true for copyable fields in a card cipher", () => { + const cipherListView = { + type: { card: { brand: "MasterCard" } }, + copyableFields: ["CardNumber", "CardSecurityCode"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "cardNumber")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "securityCode")).toBe(true); + }); + + it("returns true for copyable fields in an sshKey ciphers", () => { + const cipherListView = { + type: "sshKey", + copyableFields: ["SshKey"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "privateKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "keyFingerprint")).toBe(true); + }); + + it("returns true for copyable fields in an identity cipher", () => { + const cipherListView = { + type: "identity", + copyableFields: ["IdentityUsername", "IdentityEmail", "IdentityPhone"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "username")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "email")).toBe(true); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(true); + }); + + it("returns false for when missing a field", () => { + const cipherListView = { + type: { login: {} }, + copyableFields: ["LoginUsername"], + } as CipherListView; + + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "password")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "phone")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "address")).toBe(false); + expect(CipherViewLikeUtils.hasCopyableValue(cipherListView, "publicKey")).toBe(false); + }); + }); + }); + + describe("hasFido2Credentials", () => { + describe("CipherView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + cipherView.login.fido2Credentials = [new Fido2CredentialView()]; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherView = createCipherView(CipherType.Login); + cipherView.login = new LoginView(); + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherView)).toBe(false); + }); + }); + + describe("CipherListView", () => { + it("returns true when the login has FIDO2 credentials", () => { + const cipherListView = { + type: { login: { fido2Credentials: [{ credentialId: "fido2-1" }] } }, + } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(true); + }); + + it("returns false when the login has no FIDO2 credentials", () => { + const cipherListView = { type: { login: {} } } as CipherListView; + + expect(CipherViewLikeUtils.hasFido2Credentials(cipherListView)).toBe(false); + }); + }); + }); + + describe("decryptionFailure", () => { + it("returns true when the cipher has a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = true; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(true); + }); + + it("returns false when the cipher does not have a decryption failure", () => { + const cipherView = createCipherView(); + cipherView.decryptionFailure = false; + + expect(CipherViewLikeUtils.decryptionFailure(cipherView)).toBe(false); + }); + + it("returns false when the cipher is a CipherListView without decryptionFailure", () => { + const cipherListView = { type: "secureNote" } as CipherListView; + + expect(CipherViewLikeUtils.decryptionFailure(cipherListView)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts new file mode 100644 index 00000000000..1c7a4382a04 --- /dev/null +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -0,0 +1,301 @@ +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { + CardListView, + CipherListView, + CopyableCipherFields, + LoginListView, + LoginUriView as LoginListUriView, +} from "@bitwarden/sdk-internal"; + +import { CipherType } from "../enums"; +import { Cipher } from "../models/domain/cipher"; +import { CardView } from "../models/view/card.view"; +import { CipherView } from "../models/view/cipher.view"; +import { LoginUriView } from "../models/view/login-uri.view"; +import { LoginView } from "../models/view/login.view"; + +/** + * Type union of {@link CipherView} and {@link CipherListView}. + */ +export type CipherViewLike = CipherView | CipherListView; + +/** + * Utility class for working with ciphers that can be either a {@link CipherView} or a {@link CipherListView}. + */ +export class CipherViewLikeUtils { + /** @returns true when the given cipher is an instance of {@link CipherListView}. */ + static isCipherListView = (cipher: CipherViewLike | Cipher): cipher is CipherListView => { + return typeof cipher.type === "object" || typeof cipher.type === "string"; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getLogin = (cipher: CipherViewLike): LoginListView | LoginView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "login" in cipher.type ? cipher.type.login : null; + } + + return cipher.type === CipherType.Login ? cipher.login : null; + }; + + /** @returns The first URI for a login cipher. If the cipher is not of type Login or has no associated URIs, returns null. */ + static uri = (cipher: CipherViewLike) => { + const login = this.getLogin(cipher); + if (!login) { + return null; + } + + if ("uri" in login) { + return login.uri; + } + + return login.uris?.length ? login.uris[0].uri : null; + }; + + /** @returns The login object from the input cipher. If the cipher is not of type Login, returns null. */ + static getCard = (cipher: CipherViewLike): CardListView | CardView | null => { + if (this.isCipherListView(cipher)) { + if (typeof cipher.type !== "object") { + return null; + } + + return "card" in cipher.type ? cipher.type.card : null; + } + + return cipher.type === CipherType.Card ? cipher.card : null; + }; + + /** @returns `true` when the cipher has been deleted, `false` otherwise. */ + static isDeleted = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return !!cipher.deletedDate; + } + + return cipher.isDeleted; + }; + + /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */ + static canAssignToCollections = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + if (!cipher.organizationId) { + return true; + } + + return cipher.edit && cipher.viewPassword; + } + + return cipher.canAssignToCollections; + }; + + /** + * Returns the type of the cipher. + * For consistency, when the given cipher is a {@link CipherListView} the {@link CipherType} equivalent will be returned. + */ + static getType = (cipher: CipherViewLike | Cipher): CipherType => { + if (!this.isCipherListView(cipher)) { + return cipher.type; + } + + // CipherListViewType is a string, so we need to map it to CipherType. + switch (true) { + case cipher.type === "secureNote": + return CipherType.SecureNote; + case cipher.type === "sshKey": + return CipherType.SshKey; + case cipher.type === "identity": + return CipherType.Identity; + case typeof cipher.type === "object" && "card" in cipher.type: + return CipherType.Card; + case typeof cipher.type === "object" && "login" in cipher.type: + return CipherType.Login; + default: + throw new Error(`Unknown cipher type: ${cipher.type}`); + } + }; + + /** @returns The subtitle of the cipher. */ + static subtitle = (cipher: CipherViewLike): string | undefined => { + if (!this.isCipherListView(cipher)) { + return cipher.subTitle; + } + + return cipher.subtitle; + }; + + /** @returns `true` when the cipher has attachments, false otherwise. */ + static hasAttachments = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return typeof cipher.attachments === "number" && cipher.attachments > 0; + } + + return cipher.hasAttachments; + }; + + /** + * @returns `true` when one of the URIs for the cipher can be launched. + * When a non-login cipher is passed, it will return false. + */ + static canLaunch = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + if (!login) { + return false; + } + + return !!login.uris?.map((u) => toLoginUriView(u)).some((uri) => uri.canLaunch); + }; + + /** + * @returns The first launch-able URI for the cipher. + * When a non-login cipher is passed or none of the URLs, it will return undefined. + */ + static getLaunchUri = (cipher: CipherViewLike): string | undefined => { + const login = this.getLogin(cipher); + + if (!login) { + return undefined; + } + + return login.uris?.map((u) => toLoginUriView(u)).find((uri) => uri.canLaunch)?.launchUri; + }; + + /** + * @returns `true` when the `targetUri` matches for any URI on the cipher. + * Uses the existing logic from `LoginView.matchesUri` for both `CipherView` and `CipherListView` + */ + static matchesUri = ( + cipher: CipherViewLike, + targetUri: string, + equivalentDomains: Set, + defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + ): boolean => { + if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { + return false; + } + + if (!this.isCipherListView(cipher)) { + return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + } + + const login = this.getLogin(cipher); + if (!login?.uris?.length) { + return false; + } + + const loginUriViews = login.uris + .filter((u) => !!u.uri) + .map((u) => { + const view = new LoginUriView(); + view.match = u.match ?? defaultUriMatch; + view.uri = u.uri!; // above `filter` ensures `u.uri` is not null or undefined + return view; + }); + + return loginUriViews.some((uriView) => + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + ); + }; + + /** @returns true when the `copyField` is populated on the given cipher. */ + static hasCopyableValue = (cipher: CipherViewLike, copyField: string): boolean => { + // `CipherListView` instances do not contain the values to be copied, but rather a list of copyable fields. + // When the copy action is performed on a `CipherListView`, the full cipher will need to be decrypted. + if (this.isCipherListView(cipher)) { + let _copyField = copyField; + + if (_copyField === "username" && this.getType(cipher) === CipherType.Login) { + _copyField = "usernameLogin"; + } else if (_copyField === "username" && this.getType(cipher) === CipherType.Identity) { + _copyField = "usernameIdentity"; + } + + return cipher.copyableFields.includes(copyActionToCopyableFieldMap[_copyField]); + } + + // When the full cipher is available, check the specific field + switch (copyField) { + case "username": + return !!cipher.login?.username || !!cipher.identity?.username; + case "password": + return !!cipher.login?.password; + case "totp": + return !!cipher.login?.totp; + case "cardNumber": + return !!cipher.card?.number; + case "securityCode": + return !!cipher.card?.code; + case "email": + return !!cipher.identity?.email; + case "phone": + return !!cipher.identity?.phone; + case "address": + return !!cipher.identity?.fullAddressForCopy; + case "secureNote": + return !!cipher.notes; + case "privateKey": + return !!cipher.sshKey?.privateKey; + case "publicKey": + return !!cipher.sshKey?.publicKey; + case "keyFingerprint": + return !!cipher.sshKey?.keyFingerprint; + default: + return false; + } + }; + + /** @returns true when the cipher has fido2 credentials */ + static hasFido2Credentials = (cipher: CipherViewLike): boolean => { + const login = this.getLogin(cipher); + + return !!login?.fido2Credentials?.length; + }; + + /** + * Returns the `decryptionFailure` property from the cipher when available. + * TODO: https://bitwarden.atlassian.net/browse/PM-22515 - alter for `CipherListView` if needed + */ + static decryptionFailure = (cipher: CipherViewLike): boolean => { + return "decryptionFailure" in cipher ? cipher.decryptionFailure : false; + }; +} + +/** + * Mapping between the generic copy actions and the specific fields in a `CipherViewLike`. + */ +const copyActionToCopyableFieldMap: Record = { + usernameLogin: "LoginUsername", + password: "LoginPassword", + totp: "LoginTotp", + cardNumber: "CardNumber", + securityCode: "CardSecurityCode", + usernameIdentity: "IdentityUsername", + email: "IdentityEmail", + phone: "IdentityPhone", + address: "IdentityAddress", + secureNote: "SecureNotes", + privateKey: "SshKey", + publicKey: "SshKey", + keyFingerprint: "SshKey", +}; + +/** Converts a `LoginListUriView` to a `LoginUriView`. */ +const toLoginUriView = (uri: LoginListUriView | LoginUriView): LoginUriView => { + if (uri instanceof LoginUriView) { + return uri; + } + + const loginUriView = new LoginUriView(); + if (uri.match) { + loginUriView.match = uri.match; + } + if (uri.uri) { + loginUriView.uri = uri.uri; + } + return loginUriView; +}; diff --git a/libs/vault/src/components/copy-cipher-field.directive.spec.ts b/libs/vault/src/components/copy-cipher-field.directive.spec.ts index 0847e7147a9..a3650c68c9b 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.spec.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.spec.ts @@ -1,3 +1,9 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BitIconButtonComponent, MenuItemDirective } from "@bitwarden/components"; import { CopyCipherFieldService } from "@bitwarden/vault"; @@ -9,23 +15,31 @@ describe("CopyCipherFieldDirective", () => { copy: jest.fn().mockResolvedValue(null), totpAllowed: jest.fn().mockResolvedValue(true), }; + let mockAccountService: AccountService; + let mockCipherService: CipherService; let copyCipherFieldDirective: CopyCipherFieldDirective; beforeEach(() => { copyFieldService.copy.mockClear(); copyFieldService.totpAllowed.mockClear(); + mockAccountService = mock(); + mockAccountService.activeAccount$ = of({ id: "test-account-id" } as Account); + mockCipherService = mock(); copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, ); copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; }); describe("disabled state", () => { it("should be enabled when the field is available", async () => { copyCipherFieldDirective.action = "username"; - copyCipherFieldDirective.cipher.login.username = "test-username"; + (copyCipherFieldDirective.cipher as CipherView).login.username = "test-username"; await copyCipherFieldDirective.ngOnChanges(); @@ -35,6 +49,7 @@ describe("CopyCipherFieldDirective", () => { it("should be disabled when the field is not available", async () => { // create empty cipher copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; copyCipherFieldDirective.action = "username"; @@ -52,11 +67,15 @@ describe("CopyCipherFieldDirective", () => { copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, undefined, iconButton as unknown as BitIconButtonComponent, ); copyCipherFieldDirective.action = "password"; + copyCipherFieldDirective.cipher = new CipherView(); + copyCipherFieldDirective.cipher.type = CipherType.Login; await copyCipherFieldDirective.ngOnChanges(); @@ -70,6 +89,8 @@ describe("CopyCipherFieldDirective", () => { copyCipherFieldDirective = new CopyCipherFieldDirective( copyFieldService as unknown as CopyCipherFieldService, + mockAccountService, + mockCipherService, menuItemDirective as unknown as MenuItemDirective, ); @@ -83,9 +104,11 @@ describe("CopyCipherFieldDirective", () => { describe("login", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.login.username = "test-username"; - copyCipherFieldDirective.cipher.login.password = "test-password"; - copyCipherFieldDirective.cipher.login.totp = "test-totp"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Login; + cipher.login.username = "test-username"; + cipher.login.password = "test-password"; + cipher.login.totp = "test-totp"; }); it.each([ @@ -107,10 +130,12 @@ describe("CopyCipherFieldDirective", () => { describe("identity", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.identity.username = "test-username"; - copyCipherFieldDirective.cipher.identity.email = "test-email"; - copyCipherFieldDirective.cipher.identity.phone = "test-phone"; - copyCipherFieldDirective.cipher.identity.address1 = "test-address-1"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Identity; + cipher.identity.username = "test-username"; + cipher.identity.email = "test-email"; + cipher.identity.phone = "test-phone"; + cipher.identity.address1 = "test-address-1"; }); it.each([ @@ -133,8 +158,10 @@ describe("CopyCipherFieldDirective", () => { describe("card", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.card.number = "test-card-number"; - copyCipherFieldDirective.cipher.card.code = "test-card-code"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.Card; + cipher.card.number = "test-card-number"; + cipher.card.code = "test-card-code"; }); it.each([ @@ -155,7 +182,9 @@ describe("CopyCipherFieldDirective", () => { describe("secure note", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.notes = "test-secure-note"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.SecureNote; + cipher.notes = "test-secure-note"; }); it("copies secure note field to clipboard", async () => { @@ -173,9 +202,11 @@ describe("CopyCipherFieldDirective", () => { describe("ssh key", () => { beforeEach(() => { - copyCipherFieldDirective.cipher.sshKey.privateKey = "test-private-key"; - copyCipherFieldDirective.cipher.sshKey.publicKey = "test-public-key"; - copyCipherFieldDirective.cipher.sshKey.keyFingerprint = "test-key-fingerprint"; + const cipher = copyCipherFieldDirective.cipher as CipherView; + cipher.type = CipherType.SshKey; + cipher.sshKey.privateKey = "test-private-key"; + cipher.sshKey.publicKey = "test-public-key"; + cipher.sshKey.keyFingerprint = "test-key-fingerprint"; }); it.each([ diff --git a/libs/vault/src/components/copy-cipher-field.directive.ts b/libs/vault/src/components/copy-cipher-field.directive.ts index 0ab7400a6dd..59ad8bf38e8 100644 --- a/libs/vault/src/components/copy-cipher-field.directive.ts +++ b/libs/vault/src/components/copy-cipher-field.directive.ts @@ -1,6 +1,14 @@ import { Directive, HostBinding, HostListener, Input, OnChanges, Optional } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuItemDirective, BitIconButtonComponent } from "@bitwarden/components"; import { CopyAction, CopyCipherFieldService } from "@bitwarden/vault"; @@ -27,10 +35,12 @@ export class CopyCipherFieldDirective implements OnChanges { }) action!: Exclude; - @Input({ required: true }) cipher!: CipherView; + @Input({ required: true }) cipher!: CipherViewLike; constructor( private copyCipherFieldService: CopyCipherFieldService, + private accountService: AccountService, + private cipherService: CipherService, @Optional() private menuItemDirective?: MenuItemDirective, @Optional() private iconButtonComponent?: BitIconButtonComponent, ) {} @@ -49,7 +59,7 @@ export class CopyCipherFieldDirective implements OnChanges { @HostListener("click") async copy() { - const value = this.getValueToCopy(); + const value = await this.getValueToCopy(); await this.copyCipherFieldService.copy(value ?? "", this.action, this.cipher); } @@ -60,7 +70,7 @@ export class CopyCipherFieldDirective implements OnChanges { private async updateDisabledState() { this.disabled = !this.cipher || - !this.getValueToCopy() || + !this.hasValueToCopy() || (this.action === "totp" && !(await this.copyCipherFieldService.totpAllowed(this.cipher))) ? true : null; @@ -76,32 +86,51 @@ export class CopyCipherFieldDirective implements OnChanges { } } - private getValueToCopy() { + /** Returns `true` when the cipher has the associated value as populated. */ + private hasValueToCopy() { + return CipherViewLikeUtils.hasCopyableValue(this.cipher, this.action); + } + + /** Returns the value of the cipher to be copied. */ + private async getValueToCopy() { + let _cipher: CipherView; + + if (CipherViewLikeUtils.isCipherListView(this.cipher)) { + // When the cipher is of type `CipherListView`, the full cipher needs to be decrypted + const activeAccountId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ); + const encryptedCipher = await this.cipherService.get(this.cipher.id!, activeAccountId); + _cipher = await this.cipherService.decrypt(encryptedCipher, activeAccountId); + } else { + _cipher = this.cipher; + } + switch (this.action) { case "username": - return this.cipher.login?.username || this.cipher.identity?.username; + return _cipher.login?.username || _cipher.identity?.username; case "password": - return this.cipher.login?.password; + return _cipher.login?.password; case "totp": - return this.cipher.login?.totp; + return _cipher.login?.totp; case "cardNumber": - return this.cipher.card?.number; + return _cipher.card?.number; case "securityCode": - return this.cipher.card?.code; + return _cipher.card?.code; case "email": - return this.cipher.identity?.email; + return _cipher.identity?.email; case "phone": - return this.cipher.identity?.phone; + return _cipher.identity?.phone; case "address": - return this.cipher.identity?.fullAddressForCopy; + return _cipher.identity?.fullAddressForCopy; case "secureNote": - return this.cipher.notes; + return _cipher.notes; case "privateKey": - return this.cipher.sshKey?.privateKey; + return _cipher.sshKey?.privateKey; case "publicKey": - return this.cipher.sshKey?.publicKey; + return _cipher.sshKey?.publicKey; case "keyFingerprint": - return this.cipher.sshKey?.keyFingerprint; + return _cipher.sshKey?.keyFingerprint; default: return null; } diff --git a/libs/vault/src/services/copy-cipher-field.service.spec.ts b/libs/vault/src/services/copy-cipher-field.service.spec.ts index 5b038376aee..3bd8f911f3e 100644 --- a/libs/vault/src/services/copy-cipher-field.service.spec.ts +++ b/libs/vault/src/services/copy-cipher-field.service.spec.ts @@ -8,7 +8,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { ToastService } from "@bitwarden/components"; @@ -128,6 +128,7 @@ describe("CopyCipherFieldService", () => { describe("totp", () => { beforeEach(() => { actionType = "totp"; + cipher.type = CipherType.Login; cipher.login = new LoginView(); cipher.login.totp = "secret-totp"; cipher.reprompt = CipherRepromptType.None; diff --git a/libs/vault/src/services/copy-cipher-field.service.ts b/libs/vault/src/services/copy-cipher-field.service.ts index 3f94b27cef8..606614f2143 100644 --- a/libs/vault/src/services/copy-cipher-field.service.ts +++ b/libs/vault/src/services/copy-cipher-field.service.ts @@ -9,7 +9,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -103,7 +106,7 @@ export class CopyCipherFieldService { async copy( valueToCopy: string, actionType: CopyAction, - cipher: CipherView, + cipher: CipherViewLike, skipReprompt: boolean = false, ): Promise { const action = CopyActions[actionType]; @@ -153,13 +156,16 @@ export class CopyCipherFieldService { /** * Determines if TOTP generation is allowed for a cipher and user. */ - async totpAllowed(cipher: CipherView): Promise { + async totpAllowed(cipher: CipherViewLike): Promise { const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (!activeAccount?.id) { return false; } + + const login = CipherViewLikeUtils.getLogin(cipher); + return ( - (cipher?.login?.hasTotp ?? false) && + !!login?.totp && (cipher.organizationUseTotp || (await firstValueFrom( this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeAccount.id), diff --git a/libs/vault/src/services/password-reprompt.service.ts b/libs/vault/src/services/password-reprompt.service.ts index 6583d0787fc..e6a6b20b320 100644 --- a/libs/vault/src/services/password-reprompt.service.ts +++ b/libs/vault/src/services/password-reprompt.service.ts @@ -4,7 +4,7 @@ import { firstValueFrom, lastValueFrom } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherRepromptType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptComponent } from "../components/password-reprompt.component"; @@ -28,7 +28,7 @@ export class PasswordRepromptService { return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"]; } - async passwordRepromptCheck(cipher: CipherView) { + async passwordRepromptCheck(cipher: CipherViewLike) { if (cipher.reprompt === CipherRepromptType.None) { return true; } From 9ca265c543394a62a590ed2e1263aea9a597268b Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:24:53 -0700 Subject: [PATCH 06/54] feat(redirectToVaultIfUnlockedGuard): [Auth/PM-20623] RedirectToVaultIfUnlocked Guard (#15236) Adds a `redirect-to-vault-if-unlocked.guard.ts` that does the following: - If there is no active user, allow access to the route - If the user is specifically Unlocked, redirect the user to /vault - Otherwise, allow access to the route (fallback/default) --- apps/browser/src/popup/app-routing.module.ts | 3 + libs/angular/src/auth/guards/index.ts | 1 + .../redirect-to-vault-if-unlocked/README.md | 19 ++++ ...edirect-to-vault-if-unlocked.guard.spec.ts | 98 +++++++++++++++++++ .../redirect-to-vault-if-unlocked.guard.ts | 36 +++++++ ...auth_request_login_readme.md => README.md} | 55 ++++++++--- 6 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md create mode 100644 libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts create mode 100644 libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts rename libs/auth/src/angular/login-via-auth-request/{auth_request_login_readme.md => README.md} (77%) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 47ba2326557..9e55cfce2ce 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -12,6 +12,7 @@ import { authGuard, lockGuard, redirectGuard, + redirectToVaultIfUnlockedGuard, tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; @@ -454,6 +455,7 @@ const routes: Routes = [ }, { path: "login-with-device", + canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, pageTitle: { @@ -502,6 +504,7 @@ const routes: Routes = [ }, { path: "admin-approval-requested", + canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, pageTitle: { diff --git a/libs/angular/src/auth/guards/index.ts b/libs/angular/src/auth/guards/index.ts index 8a4d0be8167..a0aadd3a4d1 100644 --- a/libs/angular/src/auth/guards/index.ts +++ b/libs/angular/src/auth/guards/index.ts @@ -4,3 +4,4 @@ export * from "./lock.guard"; export * from "./redirect/redirect.guard"; export * from "./tde-decryption-required.guard"; export * from "./unauth.guard"; +export * from "./redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard"; diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md new file mode 100644 index 00000000000..c72ddc86e15 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/README.md @@ -0,0 +1,19 @@ +# RedirectToVaultIfUnlocked Guard + +The `redirectToVaultIfUnlocked` redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route. + +This is particularly useful for routes that can handle BOTH unauthenticated AND authenticated-but-locked users (which makes the `authGuard` unusable on those routes). + +
+ +### Special Use Case - Authenticating in the Extension Popout + +Imagine a user is going through the Login with Device flow in the Extension pop*out*: + +- They open the pop*out* while on `/login-with-device` +- The approve the login from another device +- They are authenticated and routed to `/vault` while in the pop*out* + +If the `redirectToVaultIfUnlocked` were NOT applied, if this user now opens the pop*up* they would be shown the `/login-with-device`, not their `/vault`. + +But by adding the `redirectToVaultIfUnlocked` to `/login-with-device` we make sure to check if the user has already `Unlocked`, and if so, route them to `/vault` upon opening the pop*up*. diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts new file mode 100644 index 00000000000..004499beede --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from "@angular/core/testing"; +import { Router, provideRouter } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard"; + +describe("redirectToVaultIfUnlockedGuard", () => { + const activeUser: Account = { + id: "userId" as UserId, + email: "test@email.com", + emailVerified: true, + name: "Test User", + }; + + const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => { + const accountService = mock(); + const authService = mock(); + + accountService.activeAccount$ = new BehaviorSubject(activeUser); + authService.authStatusFor$.mockReturnValue(of(authStatus)); + + const testBed = TestBed.configureTestingModule({ + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: AuthService, useValue: authService }, + provideRouter([ + { path: "", component: EmptyComponent }, + { path: "vault", component: EmptyComponent }, + { + path: "guarded-route", + component: EmptyComponent, + canActivate: [redirectToVaultIfUnlockedGuard()], + }, + ]), + ], + }); + + return { + router: testBed.inject(Router), + }; + }; + + it("should be created", () => { + const { router } = setup(null, null); + expect(router).toBeTruthy(); + }); + + it("should redirect to /vault if the user is AuthenticationStatus.Unlocked", async () => { + // Arrange + const { router } = setup(activeUser, AuthenticationStatus.Unlocked); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/vault"); + }); + + it("should allow navigation to continue to the route if there is no active user", async () => { + // Arrange + const { router } = setup(null, null); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); + + it("should allow navigation to continue to the route if the user is AuthenticationStatus.LoggedOut", async () => { + // Arrange + const { router } = setup(null, AuthenticationStatus.LoggedOut); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); + + it("should allow navigation to continue to the route if the user is AuthenticationStatus.Locked", async () => { + // Arrange + const { router } = setup(null, AuthenticationStatus.Locked); + + // Act + await router.navigate(["guarded-route"]); + + // Assert + expect(router.url).toBe("/guarded-route"); + }); +}); diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts new file mode 100644 index 00000000000..c39bce06a45 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.ts @@ -0,0 +1,36 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +/** + * Redirects the user to `/vault` if they are `Unlocked`. Otherwise, it allows access to the route. + * See ./redirect-to-vault-if-unlocked/README.md for more details. + */ +export function redirectToVaultIfUnlockedGuard(): CanActivateFn { + return async () => { + const accountService = inject(AccountService); + const authService = inject(AuthService); + const router = inject(Router); + + const activeUser = await firstValueFrom(accountService.activeAccount$); + + // If there is no active user, allow access to the route + if (!activeUser) { + return true; + } + + const authStatus = await firstValueFrom(authService.authStatusFor$(activeUser.id)); + + // If user is Unlocked, redirect to vault + if (authStatus === AuthenticationStatus.Unlocked) { + return router.createUrlTree(["/vault"]); + } + + // If user is LoggedOut or Locked, allow access to the route + return true; + }; +} diff --git a/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md b/libs/auth/src/angular/login-via-auth-request/README.md similarity index 77% rename from libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md rename to libs/auth/src/angular/login-via-auth-request/README.md index 240316f788c..3396ba8698b 100644 --- a/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md +++ b/libs/auth/src/angular/login-via-auth-request/README.md @@ -1,11 +1,22 @@ -# Authentication Flows Documentation +# Login via Auth Request Documentation + +
+ +**Table of Contents** + +> - [Standard Auth Request Flows](#standard-auth-request-flows) +> - [Admin Auth Request Flow](#admin-auth-request-flow) +> - [Summary Table](#summary-table) +> - [State Management](#state-management) + +
## Standard Auth Request Flows ### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory 1. Unauthed user clicks "Login with device" -2. Navigates to /login-with-device which creates a StandardAuthRequest +2. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 3. Receives approval from a device with authRequestPublicKey(masterKey) 4. Decrypts masterKey 5. Decrypts userKey @@ -14,7 +25,7 @@ ### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory 1. Unauthed user clicks "Login with device" -2. Navigates to /login-with-device which creates a StandardAuthRequest +2. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 3. Receives approval from a device with authRequestPublicKey(userKey) 4. Decrypts userKey 5. Proceeds to vault @@ -34,9 +45,9 @@ get into this flow: ### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Approve from your other device" -4. Navigates to /login-with-device which creates a StandardAuthRequest +4. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 5. Receives approval from device with authRequestPublicKey(masterKey) 6. Decrypts masterKey 7. Decrypts userKey @@ -46,22 +57,24 @@ get into this flow: ### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Approve from your other device" -4. Navigates to /login-with-device which creates a StandardAuthRequest +4. Navigates to `/login-with-device` which creates a `StandardAuthRequest` 5. Receives approval from device with authRequestPublicKey(userKey) 6. Decrypts userKey 7. Establishes trust (if required) 8. Proceeds to vault +
+ ## Admin Auth Request Flow ### Flow: Authed SSO TD user requests admin approval 1. SSO TD user authenticates via SSO -2. Navigates to /login-initiated +2. Navigates to `/login-initiated` 3. Clicks "Request admin approval" -4. Navigates to /admin-approval-requested which creates an AdminAuthRequest +4. Navigates to `/admin-approval-requested` which creates an `AdminAuthRequest` 5. Receives approval from device with authRequestPublicKey(userKey) 6. Decrypts userKey 7. Establishes trust (if required) @@ -70,21 +83,25 @@ get into this flow: **Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock. +
+ ## Summary Table -| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* | -| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- | -| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes | -| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no | -| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes | -| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no | -| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey | +| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* | +| --------------- | ----------- | ----------------------------------------------------- | --------------------------- | ------------------------------------------------- | +| Standard Flow 1 | unauthed | "Login with device" [`/login`] | `/login-with-device` | yes | +| Standard Flow 2 | unauthed | "Login with device" [`/login`] | `/login-with-device` | no | +| Standard Flow 3 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | yes | +| Standard Flow 4 | authed | "Approve from your other device" [`/login-initiated`] | `/login-with-device` | no | +| Admin Flow | authed | "Request admin approval"
[`/login-initiated`] | `/admin-approval-requested` | NA - admin requests always send encrypted userKey | **Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY. +
+ ## State Management ### View Cache @@ -102,6 +119,8 @@ The cache is used to: 2. Allow resumption of pending auth requests 3. Enable processing of approved requests after extension close and reopen. +
+ ### Component State Variables Key state variables maintained during the authentication process: @@ -149,6 +168,8 @@ protected flow = Flow.StandardAuthRequest - Affects UI rendering and request handling - Set based on route and authentication state +
+ ### State Flow Examples #### Standard Auth Request Cache Flow @@ -186,6 +207,8 @@ protected flow = Flow.StandardAuthRequest - Either resumes monitoring or starts new request - Clears state after successful approval +
+ ### State Cleanup State cleanup occurs in several scenarios: From 99b1e7adf11a4b5e03566fce60c086ac7ace1cc4 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:40:57 -0700 Subject: [PATCH 07/54] feat(extension-login-approvals) [Auth/PM-14939 follow-up] add missing translations to browser when using extension tab (table view) (#15667) --- apps/browser/src/_locales/en/messages.json | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7b1262627b6..37d64c3416b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3479,6 +3479,12 @@ "youDeniedLoginAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3645,6 +3651,31 @@ "loginRequest": { "message": "Login request" }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, "justNow": { "message": "Just now" }, From f56e05404bed06b402350f54ff72dcdfa9fa3451 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Fri, 18 Jul 2025 08:34:47 -0400 Subject: [PATCH 08/54] build: reset desktop version to 7.0 (#15674) --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 2 +- apps/desktop/src/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 005b823253f..2ab88fed621 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.7.1", + "version": "2025.7.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 2cc106d07b0..01872408a10 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/desktop", - "version": "2025.7.1", + "version": "2025.7.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 8457cb23f74..0128692f3b4 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.7.1", + "version": "2025.7.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index 541c596c78a..e6d4a0b9b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -282,7 +282,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.7.1", + "version": "2025.7.0", "hasInstallScript": true, "license": "GPL-3.0" }, From 93f71482895791b70069f53f04fdc488d167c91a Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:47:17 -0400 Subject: [PATCH 09/54] fix(EmergencyAccess): [Auth/PM-23860] - Restore contact removal functionality (#15666) * PM-23860 - EmergencyAccessService - convert types from response model to actual constructed type to avoid structural typing issue at runtime * PM-23860 - EmergencyAccessService tests - add tests to both methods to prevent this from being possible again. --- .../models/emergency-access.ts | 40 +++++ .../services/emergency-access.service.spec.ts | 139 +++++++++++++++++- .../services/emergency-access.service.ts | 12 +- 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/auth/emergency-access/models/emergency-access.ts b/apps/web/src/app/auth/emergency-access/models/emergency-access.ts index b8ae5907bb9..51edca6e671 100644 --- a/apps/web/src/app/auth/emergency-access/models/emergency-access.ts +++ b/apps/web/src/app/auth/emergency-access/models/emergency-access.ts @@ -5,6 +5,10 @@ import { KdfType } from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; +import { + EmergencyAccessGranteeDetailsResponse, + EmergencyAccessGrantorDetailsResponse, +} from "../response/emergency-access.response"; export class GranteeEmergencyAccess { id: string; @@ -16,6 +20,24 @@ export class GranteeEmergencyAccess { waitTimeDays: number; creationDate: string; avatarColor: string; + + constructor(partial: Partial = {}) { + Object.assign(this, partial); + } + + static fromResponse(response: EmergencyAccessGranteeDetailsResponse) { + return new GranteeEmergencyAccess({ + id: response.id, + granteeId: response.granteeId, + name: response.name, + email: response.email, + type: response.type, + status: response.status, + waitTimeDays: response.waitTimeDays, + creationDate: response.creationDate, + avatarColor: response.avatarColor, + }); + } } export class GrantorEmergencyAccess { @@ -28,6 +50,24 @@ export class GrantorEmergencyAccess { waitTimeDays: number; creationDate: string; avatarColor: string; + + constructor(partial: Partial = {}) { + Object.assign(this, partial); + } + + static fromResponse(response: EmergencyAccessGrantorDetailsResponse) { + return new GrantorEmergencyAccess({ + id: response.id, + grantorId: response.grantorId, + name: response.name, + email: response.email, + type: response.type, + status: response.status, + waitTimeDays: response.waitTimeDays, + creationDate: response.creationDate, + avatarColor: response.avatarColor, + }); + } } export class TakeoverTypeEmergencyAccess { diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index 752e9dc1ce0..05373534ce7 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -22,9 +22,11 @@ import { KdfType, KeyService } from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; +import { GranteeEmergencyAccess, GrantorEmergencyAccess } from "../models/emergency-access"; import { EmergencyAccessPasswordRequest } from "../request/emergency-access-password.request"; import { EmergencyAccessGranteeDetailsResponse, + EmergencyAccessGrantorDetailsResponse, EmergencyAccessTakeoverResponse, } from "../response/emergency-access.response"; @@ -242,11 +244,19 @@ describe("EmergencyAccessService", () => { const mockEmergencyAccess = { data: [ - createMockEmergencyAccess("0", "EA 0", EmergencyAccessStatusType.Invited), - createMockEmergencyAccess("1", "EA 1", EmergencyAccessStatusType.Accepted), - createMockEmergencyAccess("2", "EA 2", EmergencyAccessStatusType.Confirmed), - createMockEmergencyAccess("3", "EA 3", EmergencyAccessStatusType.RecoveryInitiated), - createMockEmergencyAccess("4", "EA 4", EmergencyAccessStatusType.RecoveryApproved), + createMockEmergencyAccessGranteeDetails("0", "EA 0", EmergencyAccessStatusType.Invited), + createMockEmergencyAccessGranteeDetails("1", "EA 1", EmergencyAccessStatusType.Accepted), + createMockEmergencyAccessGranteeDetails("2", "EA 2", EmergencyAccessStatusType.Confirmed), + createMockEmergencyAccessGranteeDetails( + "3", + "EA 3", + EmergencyAccessStatusType.RecoveryInitiated, + ), + createMockEmergencyAccessGranteeDetails( + "4", + "EA 4", + EmergencyAccessStatusType.RecoveryApproved, + ), ], } as ListResponse; @@ -295,9 +305,113 @@ describe("EmergencyAccessService", () => { ).rejects.toThrow("New user key is required for rotation."); }); }); + + describe("getEmergencyAccessTrusted", () => { + it("should return an empty array if no emergency access is granted", async () => { + emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue({ + data: [], + } as ListResponse); + + const result = await emergencyAccessService.getEmergencyAccessTrusted(); + + expect(result).toEqual([]); + }); + + it("should return an empty array if the API returns an empty response", async () => { + emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue( + null as unknown as ListResponse, + ); + + const result = await emergencyAccessService.getEmergencyAccessTrusted(); + + expect(result).toEqual([]); + }); + + it("should return a list of trusted emergency access contacts", async () => { + const mockEmergencyAccess = [ + createMockEmergencyAccessGranteeDetails("1", "EA 1", EmergencyAccessStatusType.Invited), + createMockEmergencyAccessGranteeDetails("2", "EA 2", EmergencyAccessStatusType.Invited), + createMockEmergencyAccessGranteeDetails("3", "EA 3", EmergencyAccessStatusType.Accepted), + createMockEmergencyAccessGranteeDetails("4", "EA 4", EmergencyAccessStatusType.Confirmed), + createMockEmergencyAccessGranteeDetails( + "5", + "EA 5", + EmergencyAccessStatusType.RecoveryInitiated, + ), + ]; + emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue({ + data: mockEmergencyAccess, + } as ListResponse); + + const result = await emergencyAccessService.getEmergencyAccessTrusted(); + + expect(result).toHaveLength(mockEmergencyAccess.length); + + result.forEach((access, index) => { + expect(access).toBeInstanceOf(GranteeEmergencyAccess); + + expect(access.id).toBe(mockEmergencyAccess[index].id); + expect(access.name).toBe(mockEmergencyAccess[index].name); + expect(access.status).toBe(mockEmergencyAccess[index].status); + expect(access.type).toBe(mockEmergencyAccess[index].type); + }); + }); + }); + + describe("getEmergencyAccessGranted", () => { + it("should return an empty array if no emergency access is granted", async () => { + emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue({ + data: [], + } as ListResponse); + + const result = await emergencyAccessService.getEmergencyAccessGranted(); + + expect(result).toEqual([]); + }); + + it("should return an empty array if the API returns an empty response", async () => { + emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue( + null as unknown as ListResponse, + ); + + const result = await emergencyAccessService.getEmergencyAccessGranted(); + + expect(result).toEqual([]); + }); + + it("should return a list of granted emergency access contacts", async () => { + const mockEmergencyAccess = [ + createMockEmergencyAccessGrantorDetails("1", "EA 1", EmergencyAccessStatusType.Invited), + createMockEmergencyAccessGrantorDetails("2", "EA 2", EmergencyAccessStatusType.Invited), + createMockEmergencyAccessGrantorDetails("3", "EA 3", EmergencyAccessStatusType.Accepted), + createMockEmergencyAccessGrantorDetails("4", "EA 4", EmergencyAccessStatusType.Confirmed), + createMockEmergencyAccessGrantorDetails( + "5", + "EA 5", + EmergencyAccessStatusType.RecoveryInitiated, + ), + ]; + emergencyAccessApiService.getEmergencyAccessGranted.mockResolvedValue({ + data: mockEmergencyAccess, + } as ListResponse); + + const result = await emergencyAccessService.getEmergencyAccessGranted(); + + expect(result).toHaveLength(mockEmergencyAccess.length); + + result.forEach((access, index) => { + expect(access).toBeInstanceOf(GrantorEmergencyAccess); + + expect(access.id).toBe(mockEmergencyAccess[index].id); + expect(access.name).toBe(mockEmergencyAccess[index].name); + expect(access.status).toBe(mockEmergencyAccess[index].status); + expect(access.type).toBe(mockEmergencyAccess[index].type); + }); + }); + }); }); -function createMockEmergencyAccess( +function createMockEmergencyAccessGranteeDetails( id: string, name: string, status: EmergencyAccessStatusType, @@ -309,3 +423,16 @@ function createMockEmergencyAccess( emergencyAccess.status = status; return emergencyAccess; } + +function createMockEmergencyAccessGrantorDetails( + id: string, + name: string, + status: EmergencyAccessStatusType, +): EmergencyAccessGrantorDetailsResponse { + const emergencyAccess = new EmergencyAccessGrantorDetailsResponse({}); + emergencyAccess.id = id; + emergencyAccess.name = name; + emergencyAccess.type = 0; + emergencyAccess.status = status; + return emergencyAccess; +} diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 673ab7443f9..a814af32505 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -77,14 +77,22 @@ export class EmergencyAccessService * Gets all emergency access that the user has been granted. */ async getEmergencyAccessTrusted(): Promise { - return (await this.emergencyAccessApiService.getEmergencyAccessTrusted()).data; + const listResponse = await this.emergencyAccessApiService.getEmergencyAccessTrusted(); + if (!listResponse || listResponse.data.length === 0) { + return []; + } + return listResponse.data.map((response) => GranteeEmergencyAccess.fromResponse(response)); } /** * Gets all emergency access that the user has granted. */ async getEmergencyAccessGranted(): Promise { - return (await this.emergencyAccessApiService.getEmergencyAccessGranted()).data; + const listResponse = await this.emergencyAccessApiService.getEmergencyAccessGranted(); + if (!listResponse || listResponse.data.length === 0) { + return []; + } + return listResponse.data.map((response) => GrantorEmergencyAccess.fromResponse(response)); } /** From 4d1171dd5a7824dcec02504a394143d2f64217e8 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Fri, 18 Jul 2025 10:04:12 -0400 Subject: [PATCH 10/54] [CL-456] Add container story (#15621) * add container story * Update libs/components/src/container/container.component.ts Co-authored-by: Vicki League * use lorem ipsum for example --------- Co-authored-by: Vicki League --- .../src/container/container.component.ts | 2 +- libs/components/src/container/container.mdx | 13 +++++++ .../src/container/container.stories.ts | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 libs/components/src/container/container.mdx create mode 100644 libs/components/src/container/container.stories.ts diff --git a/libs/components/src/container/container.component.ts b/libs/components/src/container/container.component.ts index 2f9e15c06b8..9f6a4cbef94 100644 --- a/libs/components/src/container/container.component.ts +++ b/libs/components/src/container/container.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; /** - * Generic container that constrains page content width. + * bit-container is a minimally styled component that limits the max width of its content to the tailwind theme variable '4xl'. '4xl' is equal to the value of 56rem */ @Component({ selector: "bit-container", diff --git a/libs/components/src/container/container.mdx b/libs/components/src/container/container.mdx new file mode 100644 index 00000000000..35e0c69587f --- /dev/null +++ b/libs/components/src/container/container.mdx @@ -0,0 +1,13 @@ +import { Meta, Primary, Title, Description } from "@storybook/addon-docs"; + +import * as stories from "./container.stories"; + + + +```ts +import { ContainerComponent } from "@bitwarden/components"; +``` + + +<Description /> +<Primary /> diff --git a/libs/components/src/container/container.stories.ts b/libs/components/src/container/container.stories.ts new file mode 100644 index 00000000000..7d9078db638 --- /dev/null +++ b/libs/components/src/container/container.stories.ts @@ -0,0 +1,34 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { ContainerComponent } from "./container.component"; + +export default { + title: "Component Library/Container", + component: ContainerComponent, + decorators: [ + moduleMetadata({ + imports: [ContainerComponent], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-47329&t=k6OTDDPZOTtypRqo-11", + }, + }, +} as Meta<ContainerComponent>; + +type Story = StoryObj<ContainerComponent>; + +export const Container: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + <bit-container> + <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada felis nulla, dignissim suscipit metus posuere vel. Duis eget porttitor arcu. Praesent tempor sodales nisi ut rhoncus. Curabitur vel enim eget est elementum finibus nec vitae erat. Duis dapibus, purus varius porttitor facilisis, justo nibh scelerisque tortor, consequat eleifend augue mi et nisi. Pellentesque convallis eget sem vitae malesuada. In hac habitasse platea dictumst. Suspendisse vulputate, neque in feugiat ultricies, mi diam malesuada tellus, at ultrices nisi enim nec nunc. Integer sapien mi, facilisis sed ultrices eget, dapibus sed velit. Aenean convallis nulla id lacus mattis gravida.<p> + + <p>Etiam quis ipsum in risus euismod sagittis ac vel lorem. Donec eget mollis augue. Maecenas vitae libero ornare felis sagittis consequat et nec urna. Integer velit sapien, mollis non magna consectetur, laoreet placerat risus. Pellentesque bibendum ante in diam commodo imperdiet. Donec ante ligula, interdum eu facilisis non, commodo eu dolor. Cras rutrum imperdiet tortor eget finibus. Donec fringilla vitae libero sed tincidunt. Quisque nulla quam, consectetur et dictum sit amet, ultrices quis tortor. Cras lacinia, lacus sed venenatis luctus, risus odio ultricies lacus, eu lacinia sapien nisl vel augue. Nunc fermentum ac nisl at dictum. Nulla gravida, odio ut pellentesque commodo, sapien urna ultrices enim, ut euismod odio nisi ac justo. Pellentesque auctor erat sit amet semper convallis. In finibus enim in lorem commodo, id pretium ligula finibus. Cras vehicula nisl eget gravida dapibus.</p> + </bit-container> + `, + }), +}; From 8e185e023afbe83e4037b9a0f40dbab92d226a3e Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 18 Jul 2025 07:07:22 -0700 Subject: [PATCH 11/54] fix(extension-login-approval): [Auth/PM-14939 follow-up-2] feature flag route in Extension (#15668) --- apps/browser/src/popup/app-routing.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 9e55cfce2ce..52a60d9c23d 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -268,7 +268,7 @@ const routes: Routes = [ { path: "device-management", component: ExtensionDeviceManagementComponent, - canActivate: [authGuard], + canActivate: [canAccessFeature(FeatureFlag.PM14938_BrowserExtensionLoginApproval), authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { From 8811ec41abf31a500d82ae4364c6af1641e17361 Mon Sep 17 00:00:00 2001 From: Colton Hurst <colton@coltonhurst.com> Date: Fri, 18 Jul 2025 10:30:19 -0400 Subject: [PATCH 12/54] [PM-22788] Add Autotype Crate and Windowing Functions (#15317) * [PM-22783] Add initial feature flag and settings toggle for autotype MVP * [PM-22783] Undo Cargo.lock changes * [PM-22783] Disable console.log block * [PM-22783] Lint fix * [PM-22783] Small updates * [PM-22783] Build fix * [PM-22783] Use combineLatest in updating the desktop autotype service * [PM-22783] Check if the user is on Windows * [PM-22783] Undo access selector html change, linting keeps removing this * [PM-22783] Fix failing test * [PM-22788] Add initial desktop native autotype crate based on spike ticket investigation * [PM-22788] cargo fmt * [PM-22783] Update autotypeEnabled to be stored in service * [PM-22783] Add todo comments * [PM-22783] Add SlimConfigService and MainDesktopAutotypeService * [PM-22783] Small fixes * [PM-22788] Add get_foreground_window_title() and cleanup * [PM-22788] Add comment * [PM-22788] Lint and cross platform build fixes * [PM-22788] Update windows.rs in autotype_internal * [PM-22788] Update windows.rs and dummy.rs in autotype_internal * [PM-22788] cargo fmt * [PM-22788] Edit napi result types * [PM-22788] Edit napi result types again * [PM-22788] Add autofill as a codeowner of the desktop_native/autotype directory * [PM-22788] Refactor autotype code * [PM-22788] Move autotype dependency out of windows only due to abstraction change * [PM-22788] Fix lint errors * [PM-22788] Updates based on PR comments * [PM-22788] cargo fmt --- .github/CODEOWNERS | 1 + apps/desktop/desktop_native/Cargo.lock | 29 ++++--- apps/desktop/desktop_native/Cargo.toml | 4 +- .../desktop_native/autotype/Cargo.toml | 10 +++ .../desktop_native/autotype/src/lib.rs | 12 +++ .../desktop_native/autotype/src/linux.rs | 3 + .../desktop_native/autotype/src/macos.rs | 3 + .../desktop_native/autotype/src/windows.rs | 75 +++++++++++++++++++ apps/desktop/desktop_native/napi/Cargo.toml | 1 + apps/desktop/desktop_native/napi/index.d.ts | 3 + apps/desktop/desktop_native/napi/src/lib.rs | 12 +++ .../main/main-desktop-autotype.service.ts | 6 ++ 12 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/desktop_native/autotype/Cargo.toml create mode 100644 apps/desktop/desktop_native/autotype/src/lib.rs create mode 100644 apps/desktop/desktop_native/autotype/src/linux.rs create mode 100644 apps/desktop/desktop_native/autotype/src/macos.rs create mode 100644 apps/desktop/desktop_native/autotype/src/windows.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9502a9c404d..ef2e26916e5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -134,6 +134,7 @@ libs/common/src/autofill @bitwarden/team-autofill-dev apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-dev +apps/desktop/desktop_native/autotype @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 4c514016675..70814c74106 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -349,6 +349,14 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "autotype" +version = "0.0.0" +dependencies = [ + "windows 0.61.1", + "windows-core 0.61.0", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -912,6 +920,7 @@ name = "desktop_napi" version = "0.0.0" dependencies = [ "anyhow", + "autotype", "base64", "desktop_core", "hex", @@ -3677,7 +3686,7 @@ dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", "windows-link", - "windows-result 0.3.2", + "windows-result 0.3.4", "windows-strings", ] @@ -3737,9 +3746,9 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-numerics" @@ -3753,12 +3762,12 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link", - "windows-result 0.3.2", + "windows-result 0.3.4", "windows-strings", ] @@ -3773,18 +3782,18 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ "windows-link", ] diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 8e12dded52c..21835c61585 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["napi", "core", "proxy", "macos_provider", "windows_plugin_authenticator"] +members = ["napi", "core", "proxy", "macos_provider", "windows_plugin_authenticator", "autotype"] [workspace.package] version = "0.0.0" @@ -60,7 +60,7 @@ widestring = "=1.2.0" windows = "=0.61.1" windows-core = "=0.61.0" windows-future = "=0.2.0" -windows-registry = "=0.5.1" +windows-registry = "=0.5.3" zbus = "=5.5.0" zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml new file mode 100644 index 00000000000..c8267c3e2ea --- /dev/null +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "autotype" +version.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } +windows-core = { workspace = true } diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs new file mode 100644 index 00000000000..e3083422eb2 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -0,0 +1,12 @@ +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +mod windowing; + +/// Gets the title bar string for the foreground window. +/// +/// TODO: The error handling will be improved in a future PR: PM-23615 +#[allow(clippy::result_unit_err)] +pub fn get_foreground_window_title() -> std::result::Result<String, ()> { + windowing::get_foreground_window_title() +} diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs new file mode 100644 index 00000000000..aa06da21a49 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -0,0 +1,3 @@ +pub fn get_foreground_window_title() -> std::result::Result<String, ()> { + todo!("Bitwarden does not yet support Linux autotype"); +} diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs new file mode 100644 index 00000000000..12a4ca08d3e --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -0,0 +1,3 @@ +pub fn get_foreground_window_title() -> std::result::Result<String, ()> { + todo!("Bitwarden does not yet support Mac OS autotype"); +} diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs new file mode 100644 index 00000000000..d86d5dd35ae --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -0,0 +1,75 @@ +use std::ffi::OsString; +use std::os::windows::ffi::OsStringExt; + +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, +}; + +/// Gets the title bar string for the foreground window. +pub fn get_foreground_window_title() -> std::result::Result<String, ()> { + let Ok(window_handle) = get_foreground_window() else { + return Err(()); + }; + let Ok(Some(window_title)) = get_window_title(window_handle) else { + return Err(()); + }; + + Ok(window_title) +} + +/// Gets the foreground window handle. +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow +fn get_foreground_window() -> Result<HWND, ()> { + let foreground_window_handle = unsafe { GetForegroundWindow() }; + + if foreground_window_handle.is_invalid() { + return Err(()); + } + + Ok(foreground_window_handle) +} + +/// Gets the length of the window title bar text. +/// +/// TODO: Future improvement is to use GetLastError for better error handling +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw +fn get_window_title_length(window_handle: HWND) -> Result<usize, ()> { + if window_handle.is_invalid() { + return Err(()); + } + + match usize::try_from(unsafe { GetWindowTextLengthW(window_handle) }) { + Ok(length) => Ok(length), + Err(_) => Err(()), + } +} + +/// Gets the window title bar title. +/// +/// TODO: Future improvement is to use GetLastError for better error handling +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw +fn get_window_title(window_handle: HWND) -> Result<Option<String>, ()> { + if window_handle.is_invalid() { + return Err(()); + } + + let window_title_length = get_window_title_length(window_handle)?; + if window_title_length == 0 { + return Ok(None); + } + + let mut buffer: Vec<u16> = vec![0; window_title_length + 1]; // add extra space for the null character + + let window_title_length = unsafe { GetWindowTextW(window_handle, &mut buffer) }; + if window_title_length == 0 { + return Ok(None); + } + + let window_title = OsString::from_wide(&buffer); + + Ok(Some(window_title.to_string_lossy().into_owned())) +} diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 669f166e748..20b56395801 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -14,6 +14,7 @@ default = [] manual_test = [] [dependencies] +autotype = { path = "../autotype" } base64 = { workspace = true } hex = { workspace = true } anyhow = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 6ee460a8065..f554fdb12e8 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -208,3 +208,6 @@ export declare namespace logging { } export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void } +export declare namespace autotype { + export function getForegroundWindowTitle(): string +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 784bfb72086..aa271b335ad 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -865,3 +865,15 @@ pub mod logging { fn flush(&self) {} } } + +#[napi] +pub mod autotype { + #[napi] + pub fn get_foreground_window_title() -> napi::Result<String, napi::Status> { + autotype::get_foreground_window_title().map_err(|_| { + napi::Error::from_reason( + "Autotype Error: faild to get foreground window title".to_string(), + ) + }) + } +} diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts index 7de34da5aaf..e0871f86f8c 100644 --- a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts @@ -1,3 +1,5 @@ +import { autotype } from "@bitwarden/desktop-napi"; + import { DesktopAutotypeService } from "../services/desktop-autotype.service"; export class MainDesktopAutotypeService { @@ -17,6 +19,10 @@ export class MainDesktopAutotypeService { private enableAutotype() { // eslint-disable-next-line no-console console.log("Enabling Autotype..."); + + const result = autotype.getForegroundWindowTitle(); + // eslint-disable-next-line no-console + console.log("Window Title: " + result); } // TODO: this will call into desktop native code From 92bbe0a3c21d0067d029dfc3016b55a86584a2b5 Mon Sep 17 00:00:00 2001 From: Brandon Treston <btreston@bitwarden.com> Date: Fri, 18 Jul 2025 10:53:12 -0400 Subject: [PATCH 13/54] [PM-22100] Enforce restrictions based on collection type (#15336) * enforce restrictions based on collection type, set default collection type * fix ts strict errors * fix default collection enforcement in vault header * enforce default collection restrictions in vault collection row * enforce default collection restrictions in AC vault header * enforce default collection restriction for select all * fix ts strict error * switch to signal, fix feature flag * fix story * clean up * remove feature flag, move check for defaultCollecion to CollecitonView * fix test * remove unused configService * fix test: coerce null to undefined for collection Id * clean up leaky abstraction for default collection * fix ts-strict error * fix parens * rename defaultCollection getter * clean up --- .../vault-popup-list-filters.service.ts | 2 +- .../vault-header/vault-header.component.html | 107 +++++++++--------- .../vault-collection-row.component.html | 42 +++---- .../vault-collection-row.component.ts | 8 -- .../vault-header/vault-header.component.html | 73 ++++++------ .../vault-header/vault-header.component.ts | 18 +-- .../models/collection-admin.view.ts | 28 ++++- .../collections/models/collection.view.ts | 36 +++--- .../empty-vault-nudge.service.ts | 2 +- .../vault-settings-import-nudge.service.ts | 2 +- .../services/vault-filter.service.ts | 3 + .../src/vault/models/domain/tree-node.ts | 4 +- libs/importer/src/importers/base-importer.ts | 2 +- .../item-details-section.component.spec.ts | 3 +- 14 files changed, 182 insertions(+), 148 deletions(-) diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 9db5811d75c..7af6fb5f212 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -237,7 +237,7 @@ export class VaultPopupListFiltersService { return false; } - if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) { + if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id!)) { return false; } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html index dc5d4c7929e..50d34227b56 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.html @@ -25,62 +25,67 @@ </bit-breadcrumbs> <ng-container slot="title-suffix"> - <ng-container - *ngIf=" - collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo) - " - > - <button - bitIconButton="bwi-angle-down" - [bitMenuTriggerFor]="editCollectionMenu" - size="small" - type="button" - ></button> - <bit-menu #editCollectionMenu> - <ng-container *ngIf="canEditCollection"> + @if ( + collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo) + ) { + <ng-container> + <button + bitIconButton="bwi-angle-down" + [bitMenuTriggerFor]="editCollectionMenu" + size="small" + type="button" + ></button> + <bit-menu #editCollectionMenu> + <ng-container *ngIf="canEditCollection"> + <button + type="button" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Info, false)" + > + <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> + {{ "editInfo" | i18n }} + </button> + <button + type="button" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Access, false)" + > + <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> + {{ "access" | i18n }} + </button> + </ng-container> + <ng-container *ngIf="!canEditCollection && canViewCollectionInfo"> + <button + type="button" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Info, true)" + > + <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> + {{ "viewInfo" | i18n }} + </button> + <button + type="button" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Access, true)" + > + <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> + {{ "viewAccess" | i18n }} + </button> + </ng-container> <button type="button" + *ngIf="canDeleteCollection" bitMenuItem - (click)="editCollection(CollectionDialogTabType.Info, false)" + (click)="deleteCollection()" > - <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> - {{ "editInfo" | i18n }} + <span class="tw-text-danger"> + <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> + {{ "delete" | i18n }} + </span> </button> - <button - type="button" - bitMenuItem - (click)="editCollection(CollectionDialogTabType.Access, false)" - > - <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> - {{ "access" | i18n }} - </button> - </ng-container> - <ng-container *ngIf="!canEditCollection && canViewCollectionInfo"> - <button - type="button" - bitMenuItem - (click)="editCollection(CollectionDialogTabType.Info, true)" - > - <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> - {{ "viewInfo" | i18n }} - </button> - <button - type="button" - bitMenuItem - (click)="editCollection(CollectionDialogTabType.Access, true)" - > - <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> - {{ "viewAccess" | i18n }} - </button> - </ng-container> - <button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()"> - <span class="tw-text-danger"> - <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> - {{ "delete" | i18n }} - </span> - </button> - </bit-menu> - </ng-container> + </bit-menu> + </ng-container> + } <small *ngIf="loading"> <i class="bwi bwi-spinner bwi-spin tw-text-muted" diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html index 0355c938bce..ad2886b1e59 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.html @@ -1,14 +1,15 @@ <td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit"> - <input - type="checkbox" - bitCheckbox - appStopProp - *ngIf="showCheckbox" - [disabled]="disabled" - [checked]="checked" - (change)="$event ? this.checkedToggled.next() : null" - [attr.aria-label]="'collectionItemSelect' | i18n" - /> + @if (this.canEditCollection || this.canDeleteCollection) { + <input + type="checkbox" + bitCheckbox + appStopProp + [disabled]="disabled" + [checked]="checked" + (change)="$event ? this.checkedToggled.next() : null" + [attr.aria-label]="'collectionItemSelect' | i18n" + /> + } </td> <td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit"> <div aria-hidden="true"> @@ -57,16 +58,17 @@ </p> </td> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> - <button - *ngIf="canEditCollection || canDeleteCollection || canViewCollectionInfo" - [disabled]="disabled" - [bitMenuTriggerFor]="collectionOptions" - size="small" - bitIconButton="bwi-ellipsis-v" - type="button" - appA11yTitle="{{ 'options' | i18n }}" - appStopProp - ></button> + @if (canEditCollection || canDeleteCollection || canViewCollectionInfo) { + <button + [disabled]="disabled" + [bitMenuTriggerFor]="collectionOptions" + size="small" + bitIconButton="bwi-ellipsis-v" + type="button" + appA11yTitle="{{ 'options' | i18n }}" + appStopProp + ></button> + } <bit-menu #collectionOptions> <ng-container *ngIf="canEditCollection"> <button type="button" bitMenuItem (click)="edit(false)"> diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index 5d2b84aa10b..8271cc5a266 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -105,12 +105,4 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> { protected deleteCollection() { this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] }); } - - protected get showCheckbox() { - if (this.collection?.id === Unassigned) { - return false; // Never show checkbox for Unassigned - } - - return this.canEditCollection || this.canDeleteCollection; - } } diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html index 711d1166d7b..413b4792f4e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.html @@ -22,41 +22,48 @@ </bit-breadcrumbs> <ng-container slot="title-suffix"> - <ng-container *ngIf="collection != null && (canEditCollection || canDeleteCollection)"> - <button - bitIconButton="bwi-angle-down" - [bitMenuTriggerFor]="editCollectionMenu" - size="small" - type="button" - aria-haspopup="true" - ></button> - <bit-menu #editCollectionMenu> + @if (collection != null && (canEditCollection || canDeleteCollection)) { + <ng-container> <button + bitIconButton="bwi-angle-down" + [bitMenuTriggerFor]="editCollectionMenu" + size="small" type="button" - *ngIf="canEditCollection" - bitMenuItem - (click)="editCollection(CollectionDialogTabType.Info)" - > - <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> - {{ "editInfo" | i18n }} - </button> - <button - type="button" - *ngIf="canEditCollection" - bitMenuItem - (click)="editCollection(CollectionDialogTabType.Access)" - > - <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> - {{ "access" | i18n }} - </button> - <button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()"> - <span class="tw-text-danger"> - <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> - {{ "delete" | i18n }} - </span> - </button> - </bit-menu> - </ng-container> + aria-haspopup="true" + ></button> + <bit-menu #editCollectionMenu> + <button + type="button" + *ngIf="canEditCollection" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Info)" + > + <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> + {{ "editInfo" | i18n }} + </button> + <button + type="button" + *ngIf="canEditCollection" + bitMenuItem + (click)="editCollection(CollectionDialogTabType.Access)" + > + <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> + {{ "access" | i18n }} + </button> + <button + type="button" + *ngIf="canDeleteCollection" + bitMenuItem + (click)="deleteCollection()" + > + <span class="tw-text-danger"> + <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> + {{ "delete" | i18n }} + </span> + </button> + </bit-menu> + </ng-container> + } <small *ngIf="loading"> <i class="bwi bwi-spinner bwi-spin tw-text-muted" diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index f4f3ba32428..13b2ce2939f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; import { Router } from "@angular/router"; @@ -60,10 +58,10 @@ export class VaultHeaderComponent { * Boolean to determine the loading state of the header. * Shows a loading spinner if set to true */ - @Input() loading: boolean; + @Input() loading: boolean = true; /** Current active filter */ - @Input() filter: RoutedVaultFilterModel; + @Input() filter: RoutedVaultFilterModel | undefined; /** All organizations that can be shown */ @Input() organizations: Organization[] = []; @@ -72,7 +70,7 @@ export class VaultHeaderComponent { @Input() collection?: TreeNode<CollectionView>; /** Whether 'Collection' option is shown in the 'New' dropdown */ - @Input() canCreateCollections: boolean; + @Input() canCreateCollections: boolean = false; /** Emits an event when the new item button is clicked in the header */ @Output() onAddCipher = new EventEmitter<CipherType | undefined>(); @@ -106,7 +104,7 @@ export class VaultHeaderComponent { return this.collection.node.organizationId; } - if (this.filter.organizationId !== undefined) { + if (this.filter?.organizationId !== undefined) { return this.filter.organizationId; } @@ -119,10 +117,14 @@ export class VaultHeaderComponent { } protected get showBreadcrumbs() { - return this.filter.collectionId !== undefined && this.filter.collectionId !== All; + return this.filter?.collectionId !== undefined && this.filter.collectionId !== All; } protected get title() { + if (this.filter === undefined) { + return ""; + } + if (this.filter.collectionId === Unassigned) { return this.i18nService.t("unassigned"); } @@ -144,7 +146,7 @@ export class VaultHeaderComponent { } protected get icon() { - return this.filter.collectionId && this.filter.collectionId !== All + return this.filter?.collectionId && this.filter.collectionId !== All ? "bwi-collection-shared" : ""; } diff --git a/libs/admin-console/src/common/collections/models/collection-admin.view.ts b/libs/admin-console/src/common/collections/models/collection-admin.view.ts index cfc9996cd7a..dd7a57013ca 100644 --- a/libs/admin-console/src/common/collections/models/collection-admin.view.ts +++ b/libs/admin-console/src/common/collections/models/collection-admin.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CollectionAccessSelectionView } from "./collection-access-selection.view"; @@ -16,12 +14,12 @@ export class CollectionAdminView extends CollectionView { * Flag indicating the collection has no active user or group assigned to it with CanManage permissions * In this case, the collection can be managed by admins/owners or custom users with appropriate permissions */ - unmanaged: boolean; + unmanaged: boolean = false; /** * Flag indicating the user has been explicitly assigned to this Collection */ - assigned: boolean; + assigned: boolean = false; constructor(response?: CollectionAccessDetailsResponse) { super(response); @@ -45,6 +43,10 @@ export class CollectionAdminView extends CollectionView { * Returns true if the user can edit a collection (including user and group access) from the Admin Console. */ override canEdit(org: Organization): boolean { + if (this.isDefaultCollection) { + return false; + } + return ( org?.canEditAnyCollection || (this.unmanaged && org?.canEditUnmanagedCollections) || @@ -56,6 +58,10 @@ export class CollectionAdminView extends CollectionView { * Returns true if the user can delete a collection from the Admin Console. */ override canDelete(org: Organization): boolean { + if (this.isDefaultCollection) { + return false; + } + return org?.canDeleteAnyCollection || super.canDelete(org); } @@ -63,6 +69,10 @@ export class CollectionAdminView extends CollectionView { * Whether the user can modify user access to this collection */ canEditUserAccess(org: Organization): boolean { + if (this.isDefaultCollection) { + return false; + } + return ( (org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org) ); @@ -72,6 +82,10 @@ export class CollectionAdminView extends CollectionView { * Whether the user can modify group access to this collection */ canEditGroupAccess(org: Organization): boolean { + if (this.isDefaultCollection) { + return false; + } + return ( (org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org) @@ -82,11 +96,13 @@ export class CollectionAdminView extends CollectionView { * Returns true if the user can view collection info and access in a read-only state from the Admin Console */ override canViewCollectionInfo(org: Organization | undefined): boolean { - if (this.isUnassignedCollection) { + if (this.isUnassignedCollection || this.isDefaultCollection) { return false; } + const isAdmin = org?.isAdmin ?? false; + const permissions = org?.permissions.editAnyCollection ?? false; - return this.manage || org?.isAdmin || org?.permissions.editAnyCollection; + return this.manage || isAdmin || permissions; } /** diff --git a/libs/admin-console/src/common/collections/models/collection.view.ts b/libs/admin-console/src/common/collections/models/collection.view.ts index 7baf2e2b718..bce1d558f96 100644 --- a/libs/admin-console/src/common/collections/models/collection.view.ts +++ b/libs/admin-console/src/common/collections/models/collection.view.ts @@ -1,27 +1,25 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { View } from "@bitwarden/common/models/view/view"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; -import { Collection, CollectionType } from "./collection"; +import { Collection, CollectionType, CollectionTypes } from "./collection"; import { CollectionAccessDetailsResponse } from "./collection.response"; export const NestingDelimiter = "/"; export class CollectionView implements View, ITreeNodeObject { - id: string = null; - organizationId: string = null; - name: string = null; - externalId: string = null; + id: string | undefined; + organizationId: string | undefined; + name: string | undefined; + externalId: string | undefined; // readOnly applies to the items within a collection - readOnly: boolean = null; - hidePasswords: boolean = null; - manage: boolean = null; - assigned: boolean = null; - type: CollectionType = null; + readOnly: boolean = false; + hidePasswords: boolean = false; + manage: boolean = false; + assigned: boolean = false; + type: CollectionType = CollectionTypes.SharedCollection; constructor(c?: Collection | CollectionAccessDetailsResponse) { if (!c) { @@ -57,7 +55,11 @@ export class CollectionView implements View, ITreeNodeObject { * Returns true if the user can edit a collection (including user and group access) from the individual vault. * Does not include admin permissions - see {@link CollectionAdminView.canEdit}. */ - canEdit(org: Organization): boolean { + canEdit(org: Organization | undefined): boolean { + if (this.isDefaultCollection) { + return false; + } + if (org != null && org.id !== this.organizationId) { throw new Error( "Id of the organization provided does not match the org id of the collection.", @@ -71,7 +73,7 @@ export class CollectionView implements View, ITreeNodeObject { * Returns true if the user can delete a collection from the individual vault. * Does not include admin permissions - see {@link CollectionAdminView.canDelete}. */ - canDelete(org: Organization): boolean { + canDelete(org: Organization | undefined): boolean { if (org != null && org.id !== this.organizationId) { throw new Error( "Id of the organization provided does not match the org id of the collection.", @@ -81,7 +83,7 @@ export class CollectionView implements View, ITreeNodeObject { const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin; // Only use individual permissions, not admin permissions - return canDeleteManagedCollections && this.manage; + return canDeleteManagedCollections && this.manage && !this.isDefaultCollection; } /** @@ -94,4 +96,8 @@ export class CollectionView implements View, ITreeNodeObject { static fromJSON(obj: Jsonify<CollectionView>) { return Object.assign(new CollectionView(new Collection()), obj); } + + get isDefaultCollection() { + return this.type == CollectionTypes.DefaultUserCollection; + } } diff --git a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts index 8302ff541aa..d90ae06a75f 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -42,7 +42,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService { const orgIds = new Set(orgs.map((org) => org.id)); const canCreateCollections = orgs.some((org) => org.canCreateNewCollections); const hasManageCollections = collections.some( - (c) => c.manage && orgIds.has(c.organizationId), + (c) => c.manage && orgIds.has(c.organizationId!), ); // When the user has dismissed the nudge or spotlight, return the nudge status directly diff --git a/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts index 2d86c76dff7..df0403ba4ab 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts @@ -46,7 +46,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService { const orgIds = new Set(orgs.map((org) => org.id)); const canCreateCollections = orgs.some((org) => org.canCreateNewCollections); const hasManageCollections = collections.some( - (c) => c.manage && orgIds.has(c.organizationId), + (c) => c.manage && orgIds.has(c.organizationId!), ); // When the user has dismissed the nudge or spotlight, return the nudge status directly diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index fea57743055..0d633be868e 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -191,6 +191,9 @@ export function sortDefaultCollections( .sort((a, b) => { const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId; const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId; + if (!aName || !bName) { + throw new Error("Collection does not have an organizationId."); + } return collator.compare(aName, bName); }); return [ diff --git a/libs/common/src/vault/models/domain/tree-node.ts b/libs/common/src/vault/models/domain/tree-node.ts index 7af1d9e6ab4..7ba8e593908 100644 --- a/libs/common/src/vault/models/domain/tree-node.ts +++ b/libs/common/src/vault/models/domain/tree-node.ts @@ -16,6 +16,6 @@ export class TreeNode<T extends ITreeNodeObject> { } export interface ITreeNodeObject { - id: string; - name: string; + id: string | undefined; + name: string | undefined; } diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 9033997a475..463d61dbbdf 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -279,7 +279,7 @@ export abstract class BaseImporter { result.collections = result.folders.map((f) => { const collection = new CollectionView(); collection.name = f.name; - collection.id = f.id; + collection.id = f.id ?? undefined; // folder id may be null, which is not suitable for collections. return collection; }); result.folderRelationships = []; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index dfa0f9a89ca..db8e2007c61 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -31,7 +31,7 @@ const createMockCollection = ( organizationId: string, readOnly = false, canEdit = true, -) => { +): CollectionView => { return { id, name, @@ -42,6 +42,7 @@ const createMockCollection = ( manage: true, assigned: true, type: CollectionTypes.DefaultUserCollection, + isDefaultCollection: true, canEditItems: jest.fn().mockReturnValue(canEdit), canEdit: jest.fn(), canDelete: jest.fn(), From 5b1ddc91227f086a59662d55ec65bee8ab121b06 Mon Sep 17 00:00:00 2001 From: Vicki League <vleague@bitwarden.com> Date: Fri, 18 Jul 2025 11:47:51 -0400 Subject: [PATCH 14/54] [CL-793] Exclude checkbox component from desktop global css (#15675) --- apps/desktop/src/scss/base.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/scss/base.scss b/apps/desktop/src/scss/base.scss index 494e91529ee..a95d82dacd4 100644 --- a/apps/desktop/src/scss/base.scss +++ b/apps/desktop/src/scss/base.scss @@ -66,7 +66,7 @@ a { } } -input:not(bit-form-field input), +input:not(bit-form-field input, input[bitcheckbox]), select, textarea:not(bit-form-field textarea) { @include themify($themes) { From 367f7a108cfa876ff5861699a0f0bc1e6ce2659c Mon Sep 17 00:00:00 2001 From: SmithThe4th <gsmith@bitwarden.com> Date: Fri, 18 Jul 2025 14:09:19 -0400 Subject: [PATCH 15/54] Exclude Linked field type for ssh keys (#15662) --- .../add-edit-custom-field-dialog.component.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts index 7ddcf902d70..7d56db4366b 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.ts @@ -71,8 +71,11 @@ export class AddEditCustomFieldDialogComponent { if (this.data.disallowHiddenField && option.value === FieldType.Hidden) { return false; } - // Filter out the Linked field type for Secure Notes - if (this.data.cipherType === CipherType.SecureNote) { + // Filter out the Linked field type for Secure Notes and SSH Keys + if ( + this.data.cipherType === CipherType.SecureNote || + this.data.cipherType === CipherType.SshKey + ) { return option.value !== FieldType.Linked; } From 436b3567dc9894f14645fc7a30cdb327ea7dbe64 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:08:21 -0700 Subject: [PATCH 16/54] [PM-23478] - Can view org's cards in AC (#15669) * properly filter restricted item types in AC * fix storybook --- .../vault-items/vault-items.component.ts | 25 ++++++++++++++++--- .../vault-items/vault-items.stories.ts | 1 + 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index e82b03a8815..79ba9a6d2e1 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -2,11 +2,16 @@ // @ts-strict-ignore import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherViewLike, CipherViewLikeUtils, @@ -85,8 +90,12 @@ export class VaultItemsComponent<C extends CipherViewLike> { protected canDeleteSelected$: Observable<boolean>; protected canRestoreSelected$: Observable<boolean>; protected disableMenu$: Observable<boolean>; + private restrictedTypes: RestrictedCipherType[] = []; - constructor(protected cipherAuthorizationService: CipherAuthorizationService) { + constructor( + protected cipherAuthorizationService: CipherAuthorizationService, + private restrictedItemTypesService: RestrictedItemTypesService, + ) { this.canDeleteSelected$ = this.selection.changed.pipe( startWith(null), switchMap(() => { @@ -114,6 +123,11 @@ export class VaultItemsComponent<C extends CipherViewLike> { }), ); + this.restrictedItemTypesService.restricted$.pipe(takeUntilDestroyed()).subscribe((types) => { + this.restrictedTypes = types; + this.refreshItems(); + }); + this.canRestoreSelected$ = this.selection.changed.pipe( startWith(null), switchMap(() => { @@ -342,9 +356,12 @@ export class VaultItemsComponent<C extends CipherViewLike> { private refreshItems() { const collections: VaultItem<C>[] = this.collections.map((collection) => ({ collection })); - const ciphers: VaultItem<C>[] = this.ciphers.map((cipher) => ({ - cipher, - })); + const ciphers: VaultItem<C>[] = this.ciphers + .filter( + (cipher) => + !this.restrictedItemTypesService.isCipherRestricted(cipher, this.restrictedTypes), + ) + .map((cipher) => ({ cipher })); const items: VaultItem<C>[] = [].concat(collections).concat(ciphers); // All ciphers are selectable, collections only if they can be edited or deleted diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 785c07fb634..78c4d21dede 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -139,6 +139,7 @@ export default { provide: RestrictedItemTypesService, useValue: { restricted$: of([]), // No restricted item types for this story + isCipherRestricted: () => false, // No restrictions for this story }, }, ], From cdc811daf8fe9ee93e9d533cc5f082de45c6dded Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:42:12 +0200 Subject: [PATCH 17/54] Autosync the updated translations (#15672) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/af/messages.json | 23 ++++++++ apps/desktop/src/locales/ar/messages.json | 23 ++++++++ apps/desktop/src/locales/az/messages.json | 27 ++++++++- apps/desktop/src/locales/be/messages.json | 23 ++++++++ apps/desktop/src/locales/bg/messages.json | 23 ++++++++ apps/desktop/src/locales/bn/messages.json | 23 ++++++++ apps/desktop/src/locales/bs/messages.json | 23 ++++++++ apps/desktop/src/locales/ca/messages.json | 23 ++++++++ apps/desktop/src/locales/cs/messages.json | 23 ++++++++ apps/desktop/src/locales/cy/messages.json | 23 ++++++++ apps/desktop/src/locales/da/messages.json | 23 ++++++++ apps/desktop/src/locales/de/messages.json | 31 ++++++++-- apps/desktop/src/locales/el/messages.json | 23 ++++++++ apps/desktop/src/locales/en_GB/messages.json | 23 ++++++++ apps/desktop/src/locales/en_IN/messages.json | 23 ++++++++ apps/desktop/src/locales/eo/messages.json | 23 ++++++++ apps/desktop/src/locales/es/messages.json | 61 ++++++++++++++------ apps/desktop/src/locales/et/messages.json | 23 ++++++++ apps/desktop/src/locales/eu/messages.json | 23 ++++++++ apps/desktop/src/locales/fa/messages.json | 23 ++++++++ apps/desktop/src/locales/fi/messages.json | 23 ++++++++ apps/desktop/src/locales/fil/messages.json | 23 ++++++++ apps/desktop/src/locales/fr/messages.json | 23 ++++++++ apps/desktop/src/locales/gl/messages.json | 23 ++++++++ apps/desktop/src/locales/he/messages.json | 23 ++++++++ apps/desktop/src/locales/hi/messages.json | 23 ++++++++ apps/desktop/src/locales/hr/messages.json | 23 ++++++++ apps/desktop/src/locales/hu/messages.json | 23 ++++++++ apps/desktop/src/locales/id/messages.json | 23 ++++++++ apps/desktop/src/locales/it/messages.json | 23 ++++++++ apps/desktop/src/locales/ja/messages.json | 23 ++++++++ apps/desktop/src/locales/ka/messages.json | 23 ++++++++ apps/desktop/src/locales/km/messages.json | 23 ++++++++ apps/desktop/src/locales/kn/messages.json | 23 ++++++++ apps/desktop/src/locales/ko/messages.json | 23 ++++++++ apps/desktop/src/locales/lt/messages.json | 23 ++++++++ apps/desktop/src/locales/lv/messages.json | 23 ++++++++ apps/desktop/src/locales/me/messages.json | 23 ++++++++ apps/desktop/src/locales/ml/messages.json | 23 ++++++++ apps/desktop/src/locales/mr/messages.json | 23 ++++++++ apps/desktop/src/locales/my/messages.json | 23 ++++++++ apps/desktop/src/locales/nb/messages.json | 23 ++++++++ apps/desktop/src/locales/ne/messages.json | 23 ++++++++ apps/desktop/src/locales/nl/messages.json | 23 ++++++++ apps/desktop/src/locales/nn/messages.json | 23 ++++++++ apps/desktop/src/locales/or/messages.json | 23 ++++++++ apps/desktop/src/locales/pl/messages.json | 23 ++++++++ apps/desktop/src/locales/pt_BR/messages.json | 23 ++++++++ apps/desktop/src/locales/pt_PT/messages.json | 23 ++++++++ apps/desktop/src/locales/ro/messages.json | 23 ++++++++ apps/desktop/src/locales/ru/messages.json | 23 ++++++++ apps/desktop/src/locales/si/messages.json | 23 ++++++++ apps/desktop/src/locales/sk/messages.json | 23 ++++++++ apps/desktop/src/locales/sl/messages.json | 23 ++++++++ apps/desktop/src/locales/sr/messages.json | 23 ++++++++ apps/desktop/src/locales/sv/messages.json | 31 ++++++++-- apps/desktop/src/locales/te/messages.json | 23 ++++++++ apps/desktop/src/locales/th/messages.json | 23 ++++++++ apps/desktop/src/locales/tr/messages.json | 23 ++++++++ apps/desktop/src/locales/uk/messages.json | 23 ++++++++ apps/desktop/src/locales/vi/messages.json | 23 ++++++++ apps/desktop/src/locales/zh_CN/messages.json | 27 ++++++++- apps/desktop/src/locales/zh_TW/messages.json | 23 ++++++++ 63 files changed, 1480 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 68f389a40a0..3e539e48eb9 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopieer bevestigingskode (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lengte" }, @@ -1425,6 +1439,9 @@ "message": "Kopieer Sekureiteitskode", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premie-lidmaatskap" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 7b2e220fa48..26aaf141dd2 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "نسخ رمز التحقق (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "الطول" }, @@ -1425,6 +1439,9 @@ "message": "نسخ رمز الأمان", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "العضوية المميزة" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 6d6ce16b126..e5fee904d92 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Doğrulama kodunu kopyala (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Uzunluq" }, @@ -1425,6 +1439,9 @@ "message": "Güvənlik kodunu kopyala", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium üzvlük" }, @@ -3567,11 +3584,11 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Qabaqcıl seçimlər", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Xəbərdarlıq", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index ae3c8a0cc60..f4b58c89cac 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Скапіяваць праверачны код (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Даўжыня" }, @@ -1425,6 +1439,9 @@ "message": "Скапіяваць код бяспекі", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Прэміяльны статус" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index c67e1dfe829..cf74c69be46 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Код за потвърждаване (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Дължина" }, @@ -1425,6 +1439,9 @@ "message": "Копиране на кода за сигурност", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Платен абонамент" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Включване на клавишната комбинация за автоматично попълване" + }, + "enableAutotypeDescription": { + "message": "Битуорден не проверява местата за въвеждане, така че се уверете, че сте в правилния прозорец, преди да ползвате клавишната комбинация." } } diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 6f5ac9de909..4a39428590e 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "দৈর্ঘ্য" }, @@ -1425,6 +1439,9 @@ "message": "সুরক্ষা কোড অনুলিপিত করুন", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "প্রিমিয়াম সদস্যতা" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 4600780eda5..5be68fe816f 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopira Verifikacioni kod (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Dužina" }, @@ -1425,6 +1439,9 @@ "message": "Kopirajte sigurnosni kod", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium članstvo" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index a28b3b3c6d4..ed49a360aa5 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copia codi de verificació (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Longitud" }, @@ -1425,6 +1439,9 @@ "message": "Copia el codi de seguretat", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Subscripció Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 8c725808a98..11eb2113bd2 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopírovat ověřovací kód (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Délka" }, @@ -1425,6 +1439,9 @@ "message": "Kopírovat bezpečnostní kód", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Prémiové členství" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Povolit zkratku automatického psaní" + }, + "enableAutotypeDescription": { + "message": "Bitwarden neověřuje umístění vstupu. Před použitím zkratky se ujistěte, že jste ve správném okně a poli." } } diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 3b3afba9415..e3db70bf152 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index cf60c7a04cb..47df4e98b3f 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiér bekræftelseskode (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Længde" }, @@ -1425,6 +1439,9 @@ "message": "Kopiér bekræftelseskode", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium-medlemskab" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 6e2b172b97c..87ddaae531a 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Verifizierungscode (TOTP) kopieren" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Länge" }, @@ -1425,6 +1439,9 @@ "message": "Sicherheitscode kopieren", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "Kartennummer" + }, "premiumMembership": { "message": "Premium-Mitgliedschaft" }, @@ -2411,7 +2428,7 @@ "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Ändere dein Master-Passwort, um die Kontowiederherstellung abzuschließen." }, "updateMasterPasswordSubtitle": { "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." @@ -3154,7 +3171,7 @@ "message": "Admin-Genehmigung anfragen" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { "message": "You need to log in on a trusted device or ask your administrator to assign you a password." @@ -3563,7 +3580,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Mehr über die Übereinstimmungs-Erkennung", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -3571,7 +3588,7 @@ "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Warnung", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 239351d406d..2f2a3cf914c 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Αντιγραφή κωδικού επαλήθευσης (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Μήκος" }, @@ -1425,6 +1439,9 @@ "message": "Αντιγραφή κωδικού ασφαλείας", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Συνδρομή Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 57ada476677..f73e373d825 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 6c9d211bf13..ee0e0dc276f 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy Verification Code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 26e6aefb83c..5bfde4d51c0 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopii la kontrolan kodon (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Longo" }, @@ -1425,6 +1439,9 @@ "message": "Kopii sekurigan kodon", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index eb02eca59ba..f839e9c8634 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -190,7 +190,7 @@ "message": "Clave pública" }, "sshFingerprint": { - "message": "Fingerprint" + "message": "Huella digital" }, "sshKeyAlgorithm": { "message": "Tipo de clave" @@ -229,7 +229,7 @@ "message": "Por favor, desbloquea tu caja fuerte para aprobar la solicitud de clave SSH." }, "sshAgentUnlockTimeout": { - "message": "SSH key request timed out." + "message": "La solicitud de clave SSH ha expirado." }, "enableSshAgent": { "message": "Habilitar agente SSH" @@ -244,7 +244,7 @@ "message": "Solicitar autorización al usar el agente SSH" }, "sshAgentPromptBehaviorDesc": { - "message": "Choose how to handle SSH-agent authorization requests." + "message": "Elige como gestionar las solicitudes de autorización del agente SSH." }, "sshAgentPromptBehaviorHelp": { "message": "Recordar autorizaciones SSH" @@ -467,7 +467,7 @@ "message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email" }, "linkedHelpText": { - "message": "Use a linked field when you are experiencing autofill issues for a specific website." + "message": "Usa un campo enlazado cuando estés experimentando problemas de autocompletado para un sitio web específico." }, "linkedLabelHelpText": { "message": "Enter the the field's html id, name, aria-label, or placeholder." @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copiar código de verificación (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Longitud" }, @@ -1022,7 +1036,7 @@ "message": "Tiempo de autenticación agotado" }, "authenticationSessionTimedOut": { - "message": "The authentication session timed out. Please restart the login process." + "message": "La sesión de autenticación ha expirado. Por favor, inicia de nuevo el proceso de inicio de sesión." }, "selfHostBaseUrl": { "message": "URL del servidor autoalojado", @@ -1425,6 +1439,9 @@ "message": "Copiar código de seguridad", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Membresía Premium" }, @@ -2393,7 +2410,7 @@ "message": "Esta acción está protegida. Para continuar, vuelva a introducir su contraseña maestra para verificar su identidad." }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "Contraseña maestra establecida correctamente" }, "updatedMasterPassword": { "message": "Contraseña maestra actualizada" @@ -2408,13 +2425,13 @@ "message": "Tu contraseña maestra no cumple con una o más de las políticas de tu organización. Para acceder a la caja fuerte, debes actualizar tu contraseña maestra ahora. Proceder te desconectará de tu sesión actual, requiriendo que vuelva a iniciar sesión. Las sesiones activas en otros dispositivos pueden seguir estando activas durante hasta una hora." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Tras cambiar tu contraseña tendrás que iniciar sesión con tu nueva contraseña. Las sesiones activas en otros dispositivos se cerrarán en una hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Cambia tu contraseña maestra para completar la recuperación de la cuenta." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "Tu contraseña maestra no cumple con los requisitos de esta organización. Cambia tu contraseña maestra para continuar." }, "tdeDisabledMasterPasswordRequired": { "message": "Your organization has disabled trusted device encryption. Please set a master password to access your vault." @@ -2528,7 +2545,7 @@ "message": "Contraseña maestra eliminada." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "Ya no es necesaria una contraseña maestra para los miembros de la siguiente organización. Por favor, confirma el dominio que aparece a continuación con el administrador de tu organización." }, "organizationName": { "message": "Nombre de la organización" @@ -2597,7 +2614,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "message": "Solo los elementos individuales de la caja fuerte, incluyendo adjuntos asociados a $EMAIL$, serán exportados. Los elementos de la caja fuerte de la organización no se incluirán", "placeholders": { "email": { "content": "$1", @@ -2954,7 +2971,7 @@ "message": "aplicación web" }, "notificationSentDevicePart2": { - "message": "Make sure the Fingerprint phrase matches the one below before approving." + "message": "Asegúrate de que la frase de la Huella digital coincide con la siguiente antes de aprobar." }, "needAnotherOptionV1": { "message": "¿Necesitas otra opción?" @@ -3154,10 +3171,10 @@ "message": "Solicitar aprobación del administrador" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "No se puede completar el inicio de sesión" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Necesitas iniciar sesión en un dispositivo de confianza o pedir a tu administrador que te asigne una contraseña." }, "region": { "message": "Región" @@ -3212,10 +3229,10 @@ "message": "La organización no es de confianza" }, "emergencyAccessTrustWarning": { - "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" + "message": "Por la seguridad de tu cuenta, confirma únicamente si has otorgado acceso de emergencia a este usuario y que su huella digital coincida con la mostrada en su cuenta" }, "orgTrustWarning": { - "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint." + "message": "Por la seguridad de tu cuenta, procede únicamente si eres un miembro de esta organización, tienes la recuperación de la cuenta activada y la huella digital mostrada a continuación coincide con la huella digital de la organización." }, "orgTrustWarning1": { "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." @@ -3567,11 +3584,11 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Opciones avanzadas", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Advertencia", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3764,7 +3781,7 @@ "message": "Actualización de la extensión requerida" }, "updateBrowserOrDisableFingerprintDialogMessage": { - "message": "The browser extension you are using is out of date. Please update it or disable browser integration fingerprint validation in the desktop app settings." + "message": "La extensión del navegador que estás utilizando está desactualizada. Por favor, actualízala o desactiva la integración del navegador de la validación de la huella digital en los ajustes de la aplicación de escritorio." }, "changeAtRiskPassword": { "message": "Cambiar contraseña en riesgo" @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Activar atajo de autoescritura" + }, + "enableAutotypeDescription": { + "message": "Bitwarden no valida las ubicaciones de entrada, asegúrate de que estás en la ventana y en el capo correctos antes de usar el atajo." } } diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 4b848d9ef5c..be7628d2d34 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopeeri Kinnituskood (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Pikkus" }, @@ -1425,6 +1439,9 @@ "message": "Kopeeri turvakood", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Preemium versioon" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index aae6880294d..0c4b2f4d836 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiatu egiaztatze-kodea (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Luzera" }, @@ -1425,6 +1439,9 @@ "message": "Kopiatu segurtasun-kodea", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium bazkidea" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 4b9696887be..8aefda20bf4 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "کپی کد تأیید (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "طول" }, @@ -1425,6 +1439,9 @@ "message": "کپی کد امنیتی", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "عضویت پرمیوم" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 6c13f322aca..b334bc61ece 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopioi todennuskoodi (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Pituus" }, @@ -1425,6 +1439,9 @@ "message": "Kopioi turvakoodi", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium-jäsenyys" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index cfa39fd71f3..723d324bf70 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopyahin ang verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Kahabaan" }, @@ -1425,6 +1439,9 @@ "message": "Kopyahin ang code ng seguridad", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Pagiging miyembro ng premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 387c25ec44c..b06308b2906 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copier le code de vérification (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Longueur" }, @@ -1425,6 +1439,9 @@ "message": "Copier le code de sécurité", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Adhésion Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 304d07ee3cd..3d240ff77e8 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 11a736eeaaa..5b76be7fff3 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "העתקת קוד אימות (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "אורך" }, @@ -1425,6 +1439,9 @@ "message": "העתק קוד אבטחה", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "חברות פרימיום" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "הפעלת קיצור הקלדה אוטומטית" + }, + "enableAutotypeDescription": { + "message": "Bitwarden לא מאמת את מקומות הקלט, נא לוודא שזה החלון והשדה הנכונים בטרם שימוש בקיצור הדרך." } } diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 3d3a2c4d701..77b416118c9 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 699e4ad347d..b86e57a4811 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiraj kôd za provjeru (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Duljina" }, @@ -1425,6 +1439,9 @@ "message": "Kopiraj kontrolni broj", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium članstvo" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index cd30ee6edaa..f5043724cbb 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Ellenőrző kód másolása (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Hossz" }, @@ -1425,6 +1439,9 @@ "message": "Biztonsági kód másolása", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Prémium tagság" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Automatikus típusú parancsikon engedélyezése" + }, + "enableAutotypeDescription": { + "message": "A Bitwarden nem érvényesíti a beviteli helyeket, győződjünk meg róla, hogy a megfelelő ablakban és mezőben vagyunk, mielőtt a parancsikont használnánk." } } diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 0634ad80722..f4627f805e0 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Salin Kode Verifikasi (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Panjang" }, @@ -1425,6 +1439,9 @@ "message": "Salin Kode Keamanan", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Keanggotaan Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 0476024f926..b89b25a745c 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copia codice di verifica (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lunghezza" }, @@ -1425,6 +1439,9 @@ "message": "Copia codice di sicurezza", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Abbonamento Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 7b8eddc693b..9bc5f987b18 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "認証コード (TOTP) をコピー" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "長さ" }, @@ -1425,6 +1439,9 @@ "message": "セキュリティコードのコピー", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "プレミアム会員" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 7b98da76026..5f9d2fe17b3 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "სიგრძე" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 304d07ee3cd..3d240ff77e8 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 8a1798ce386..b7664ad90bf 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "ನಕಲಿಸಿ ಪರಿಶೀಲನೆ ಕೋಡ್ (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "ಉದ್ದ" }, @@ -1425,6 +1439,9 @@ "message": "ಭದ್ರತಾ ಕೋಡ್ ಅನ್ನು ನಕಲಿಸಿ", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "ಪ್ರೀಮಿಯಂ ಸದಸ್ಯತ್ವ" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 9127f4f76a9..ca23d5107c7 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "인증 코드 (TOTP) 복사" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "길이" }, @@ -1425,6 +1439,9 @@ "message": "보안 코드 복사", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "프리미엄 멤버십" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 84e5a9d36a6..99c63ab4dab 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopijuoti patvirtinimo kodą (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Ilgis" }, @@ -1425,6 +1439,9 @@ "message": "Kopijuoti saugos kodą", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium narystė" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index d1bf2382e58..7c00a3a40a9 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Ievietot Apliecinājuma kodu (TOTP) starpliktuvē" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Garums" }, @@ -1425,6 +1439,9 @@ "message": "Ievietot drošības kodu starpliktuvē", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium dalība" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Iespējot automātiskās ievades saīsni" + }, + "enableAutotypeDescription": { + "message": "Bitwarden nepārbauda ievades atrašanās vietas, jāpārliecinās, ka atrodies pareizajā logā un laukā, pirms saīsnes izmantošanas." } } diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 98794eed175..0929250c06f 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Iskopiraj Verifikacioni Kod (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Dužina" }, @@ -1425,6 +1439,9 @@ "message": "Kopiraj siguronosni kod", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premijum članstvo" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index 9cb27605db5..a77ab04c0aa 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "ദൈര്‍ഘ്യം" }, @@ -1425,6 +1439,9 @@ "message": "സുരക്ഷാ കോഡ് പകർത്തുക", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "പ്രീമിയം അംഗത്വം" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 304d07ee3cd..3d240ff77e8 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 4629ba25d93..b09ea6cdbf2 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 53125c8e290..dfa381fc3d0 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopier verifiseringskode (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lengde" }, @@ -1425,6 +1439,9 @@ "message": "Kopier sikkerhetskoden", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium-medlemskap" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 8ea29998406..56ccc79775c 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "प्रमाणीकरण कोड (TOTP) प्रतिलिपि गर्नुहोस्" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "लम्बाइ" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 90669dd1c93..5301982ecdc 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Verificatiecode kopiëren (TOTP)" }, + "copyFieldCipherName": { + "message": "$FIELD$, $CIPHERNAME$ kopiëren", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lengte" }, @@ -1425,6 +1439,9 @@ "message": "Beveiligingscode kopiëren", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "kaartnummer" + }, "premiumMembership": { "message": "Premium-abonnement" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Snelkoppeling autotype inschakelen" + }, + "enableAutotypeDescription": { + "message": "Bitwarden valideert de invoerlocaties niet, zorg ervoor dat je je in het juiste venster en veld bevindt voordat je de snelkoppeling gebruikt." } } diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index af6a64710a7..4c86afccbeb 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopier verifiseringskoden (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lengd" }, @@ -1425,6 +1439,9 @@ "message": "Kopier tryggleikskode", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium-tinging" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 3ce27e673b7..6db35fd307e 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index b09bc908c47..9852c85554a 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiuj kod weryfikacyjny (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Długość" }, @@ -1425,6 +1439,9 @@ "message": "Kopiuj kod zabezpieczający", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Konto Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index ab65e0a4912..5e570920f55 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copiar Código de Verificação (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Comprimento" }, @@ -1425,6 +1439,9 @@ "message": "Copiar Código de Segurança", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Assinatura Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 2b5d7e00f61..a9cb4fcd88e 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copiar código de verificação (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Comprimento" }, @@ -1425,6 +1439,9 @@ "message": "Copiar código de segurança", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Subscrição Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Ativar o atalho de introdução automática" + }, + "enableAutotypeDescription": { + "message": "O Bitwarden não valida a introdução de localizações. Certifique-se de que está na janela e no campo corretos antes de utilizar o atalho." } } diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index e3608836d26..41a92900d63 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copiere cod de verificare (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Lungime" }, @@ -1425,6 +1439,9 @@ "message": "Copiere cod de securitate", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Abonament Premium" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 8991d3dacb0..b87ffc3cbcd 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Скопировать код подтверждения (TOTP)" }, + "copyFieldCipherName": { + "message": "Копировать $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Длина" }, @@ -1425,6 +1439,9 @@ "message": "Скопировать код безопасности", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "номер карты" + }, "premiumMembership": { "message": "Премиум" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Включить автоввод ярлыка" + }, + "enableAutotypeDescription": { + "message": "Bitwarden не проверяет местоположение ввода, поэтому, прежде чем использовать ярлык, убедитесь, что вы находитесь в нужном окне и поле." } } diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index b50d0252f61..5567654af99 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 383e35f2826..49651fbcb7e 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopírovať overovací kód (TOTP)" }, + "copyFieldCipherName": { + "message": "Kopírovať $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Dĺžka" }, @@ -1425,6 +1439,9 @@ "message": "Kopírovať bezpečnostný kód", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "číslo karty" + }, "premiumMembership": { "message": "Prémiové členstvo" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Povoliť skratku automatického písania" + }, + "enableAutotypeDescription": { + "message": "Bitwarden neoveruje miesto stupu, pred použitím skratky sa uistite, že ste v správnom okne a poli." } } diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index db25a4623ee..23bbae2d523 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiraj verifikacijsko kodo (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Dolžina" }, @@ -1425,6 +1439,9 @@ "message": "Kopiraj varnostno kodo", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium članstvo" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 514276fb136..c69b4ca8340 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Копирај потврдни код (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Дужина" }, @@ -1425,6 +1439,9 @@ "message": "Копирај сигурносни код", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Премијум чланство" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 20140cb7ae0..067570eb002 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Kopiera verifieringskod (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Längd" }, @@ -1425,6 +1439,9 @@ "message": "Kopiera säkerhetskod", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium-medlemskap" }, @@ -3333,10 +3350,10 @@ "message": "Nyckel" }, "passkeyNotCopied": { - "message": "Nyckeln kommer inte att kopieras" + "message": "Inloggningsnyckeln kommer inte att kopieras" }, "passkeyNotCopiedAlert": { - "message": "Nyckeln kommer inte att kopieras till det klonade objektet. Vill du klona det här objektet?" + "message": "Inloggningsnyckeln kommer inte att kopieras till det klonade objektet. Vill du klona det här objektet?" }, "aliasDomain": { "message": "Aliasdomän" @@ -3587,10 +3604,10 @@ "message": "Inaktivera hårdvaruacceleration och starta om" }, "removePasskey": { - "message": "Ta bort nyckel" + "message": "Ta bort inloggningsnyckel" }, "passkeyRemoved": { - "message": "Nyckel borttagen" + "message": "Inloggningsnyckel borttagen" }, "errorAssigningTargetCollection": { "message": "Fel vid tilldelning av målsamling." @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Aktivera genväg för automatisk inmatning" + }, + "enableAutotypeDescription": { + "message": "Bitwarden validerar inte inmatningsplatser, så se till att du är i rätt fönster och fält innan du använder genvägen." } } diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 304d07ee3cd..3d240ff77e8 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Copy verification code (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Length" }, @@ -1425,6 +1439,9 @@ "message": "Copy security code", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index 678346257f2..031c9bb6364 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "คัดลอกรหัสยืนยัน (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "ความยาว" }, @@ -1425,6 +1439,9 @@ "message": "คัดลอกรหัสรักษาความปลอดภัย", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Premium Membership" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 255f03025d0..b95c585c28f 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Doğrulama kodunu kopyala (TOTP)" }, + "copyFieldCipherName": { + "message": "Kopyala: $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Uzunluk" }, @@ -1425,6 +1439,9 @@ "message": "Güvenlik kodunu kopyala", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "kart numarası" + }, "premiumMembership": { "message": "Premium üyelik" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 649b3af622f..724959446d6 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Копіювати код підтвердження (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Довжина" }, @@ -1425,6 +1439,9 @@ "message": "Копіювати код безпеки", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Преміум статус" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 42246285f80..d804c03b6cd 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "Sao chép mã xác thực (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "Độ dài" }, @@ -1425,6 +1439,9 @@ "message": "Sao chép mã bảo mật", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "Thành viên Cao Cấp" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Bật phím tắt tự động điền" + }, + "enableAutotypeDescription": { + "message": "Bitwarden không kiểm tra vị trí nhập liệu, hãy đảm bảo bạn đang ở trong đúng cửa sổ và trường nhập liệu trước khi dùng phím tắt." } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 75d8925c4f7..fe8a61af825 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "复制验证码 (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "长度" }, @@ -1425,6 +1439,9 @@ "message": "复制安全码", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "高级会员" }, @@ -3154,7 +3171,7 @@ "message": "请求管理员批准" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "无法完成登录" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { "message": "You need to log in on a trusted device or ask your administrator to assign you a password." @@ -3563,7 +3580,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "更多关于匹配检测", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "启用自动类型快捷方式" + }, + "enableAutotypeDescription": { + "message": "Bitwarden 不验证输入位置,请确保您在使用快捷键之前在正确的窗口和字段中。" } } diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 88762ea9260..541e6e82658 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -572,6 +572,20 @@ "copyVerificationCodeTotp": { "message": "複製驗證碼 (TOTP)" }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "length": { "message": "長度" }, @@ -1425,6 +1439,9 @@ "message": "複製安全代碼", "description": "Copy credit card security code (CVV)" }, + "cardNumber": { + "message": "card number" + }, "premiumMembership": { "message": "進階會員" }, @@ -3997,5 +4014,11 @@ } } } + }, + "enableAutotype": { + "message": "Enable autotype shortcut" + }, + "enableAutotypeDescription": { + "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." } } From ab9bbc8df84fe442310a31f65ac50405a8b06805 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:26:00 +0200 Subject: [PATCH 18/54] Autosync the updated translations (#15671) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 140 ++++++++++++- apps/browser/src/_locales/az/messages.json | 140 ++++++++++++- apps/browser/src/_locales/be/messages.json | 140 ++++++++++++- apps/browser/src/_locales/bg/messages.json | 140 ++++++++++++- apps/browser/src/_locales/bn/messages.json | 140 ++++++++++++- apps/browser/src/_locales/bs/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ca/messages.json | 140 ++++++++++++- apps/browser/src/_locales/cs/messages.json | 140 ++++++++++++- apps/browser/src/_locales/cy/messages.json | 140 ++++++++++++- apps/browser/src/_locales/da/messages.json | 140 ++++++++++++- apps/browser/src/_locales/de/messages.json | 144 ++++++++++++- apps/browser/src/_locales/el/messages.json | 140 ++++++++++++- apps/browser/src/_locales/en_GB/messages.json | 140 ++++++++++++- apps/browser/src/_locales/en_IN/messages.json | 140 ++++++++++++- apps/browser/src/_locales/es/messages.json | 140 ++++++++++++- apps/browser/src/_locales/et/messages.json | 140 ++++++++++++- apps/browser/src/_locales/eu/messages.json | 140 ++++++++++++- apps/browser/src/_locales/fa/messages.json | 140 ++++++++++++- apps/browser/src/_locales/fi/messages.json | 146 ++++++++++++- apps/browser/src/_locales/fil/messages.json | 140 ++++++++++++- apps/browser/src/_locales/fr/messages.json | 140 ++++++++++++- apps/browser/src/_locales/gl/messages.json | 140 ++++++++++++- apps/browser/src/_locales/he/messages.json | 140 ++++++++++++- apps/browser/src/_locales/hi/messages.json | 140 ++++++++++++- apps/browser/src/_locales/hr/messages.json | 140 ++++++++++++- apps/browser/src/_locales/hu/messages.json | 140 ++++++++++++- apps/browser/src/_locales/id/messages.json | 140 ++++++++++++- apps/browser/src/_locales/it/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ja/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ka/messages.json | 140 ++++++++++++- apps/browser/src/_locales/km/messages.json | 140 ++++++++++++- apps/browser/src/_locales/kn/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ko/messages.json | 140 ++++++++++++- apps/browser/src/_locales/lt/messages.json | 140 ++++++++++++- apps/browser/src/_locales/lv/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ml/messages.json | 140 ++++++++++++- apps/browser/src/_locales/mr/messages.json | 140 ++++++++++++- apps/browser/src/_locales/my/messages.json | 140 ++++++++++++- apps/browser/src/_locales/nb/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ne/messages.json | 140 ++++++++++++- apps/browser/src/_locales/nl/messages.json | 140 ++++++++++++- apps/browser/src/_locales/nn/messages.json | 140 ++++++++++++- apps/browser/src/_locales/or/messages.json | 140 ++++++++++++- apps/browser/src/_locales/pl/messages.json | 194 +++++++++++++++--- apps/browser/src/_locales/pt_BR/messages.json | 140 ++++++++++++- apps/browser/src/_locales/pt_PT/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ro/messages.json | 140 ++++++++++++- apps/browser/src/_locales/ru/messages.json | 140 ++++++++++++- apps/browser/src/_locales/si/messages.json | 140 ++++++++++++- apps/browser/src/_locales/sk/messages.json | 140 ++++++++++++- apps/browser/src/_locales/sl/messages.json | 140 ++++++++++++- apps/browser/src/_locales/sr/messages.json | 140 ++++++++++++- apps/browser/src/_locales/sv/messages.json | 162 +++++++++++++-- apps/browser/src/_locales/te/messages.json | 140 ++++++++++++- apps/browser/src/_locales/th/messages.json | 140 ++++++++++++- apps/browser/src/_locales/tr/messages.json | 140 ++++++++++++- apps/browser/src/_locales/uk/messages.json | 140 ++++++++++++- apps/browser/src/_locales/vi/messages.json | 140 ++++++++++++- apps/browser/src/_locales/zh_CN/messages.json | 140 ++++++++++++- apps/browser/src/_locales/zh_TW/messages.json | 140 ++++++++++++- apps/browser/store/locales/sv/copy.resx | 2 +- 61 files changed, 8204 insertions(+), 284 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 02734de942b..d7aef05ab92 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "رمز الأمان" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "مثال." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "موافقة الجهاز مطلوبة. حدّد خيار الموافقة أدناه:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 64fa77c8683..67019f5cac7 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Güvənlik kodu" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "məs." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Tələb göndərildi" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Ana parol saxlanıldı" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Gələcək girişləri problemsiz etmək üçün bu cihazı xatırla" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Cihaz təsdiqi tələb olunur. Aşağıdan bir təsdiq variantı seçin:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopyala: $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index eb721e76850..a49899eaee0 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Код бяспекі" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "напр." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Патрабуецца ўхваленне прылады. Выберыце параметры ўхвалення ніжэй:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index dad53b42e36..b86493d7d5a 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Код за сигурност" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "напр." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Заявката е изпратена" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Главната парола е запазена" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Запомняне на това устройство, така че в бъдеще вписването да бъде по-лесно" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Изисква се одобрение на устройството. Изберете начин за одобрение по-долу:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Копиране на $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index d70378f146c..4e30612b9a6 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "নিরাপত্তা কোড" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "উদাহরণ" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index afe20ff55ca..be64d0bade5 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 29859f03dd0..f6c40da1096 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Codi de seguretat" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Sol·licitud enviada" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Cal l'aprovació del dispositiu. Seleccioneu una opció d'aprovació a continuació:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 05135190fb4..86c1b650996 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Bezpečnostní kód" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "např." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Požadavek odeslán" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Požadavek na přihlášení byl schválen pro $EMAIL$ na $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Pokus o přihlášení byl zamítnut z jiného zařízení. Pokud jste to Vy, zkuste se znovu přihlásit do zařízení." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Hlavní heslo bylo uloženo" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Zapamatovat si toto zařízení pro bezproblémové budoucí přihlášení" }, + "manageDevices": { + "message": "Spravovat zařízení" + }, + "currentSession": { + "message": "Aktuální relace" + }, + "mobile": { + "message": "Mobil", + "description": "Mobile app" + }, + "extension": { + "message": "Rozšíření", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Počítač", + "description": "Desktop app" + }, + "webVault": { + "message": "Webový trezor" + }, + "webApp": { + "message": "Webová aplikace" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Čekající požadavek" + }, + "firstLogin": { + "message": "První přihlášení" + }, + "trusted": { + "message": "Důvěryhodný" + }, + "needsApproval": { + "message": "Vyžaduje schválení" + }, + "devices": { + "message": "Zařízení" + }, + "accessAttemptBy": { + "message": "Pokus o přístup z $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Potvrdit přístup" + }, + "denyAccess": { + "message": "Zamítnout přístup" + }, + "time": { + "message": "Čas" + }, + "deviceType": { + "message": "Typ zařízení" + }, + "loginRequest": { + "message": "Požadavek na přihlášení" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Právě teď" + }, + "requestedXMinutesAgo": { + "message": "Požadováno před $MINUTES$ minutami", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Vyžaduje se schválení zařízení. Vyberte možnost schválení níže:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopírovat $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index c5cbbdd189c..1235b49dd2c 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Cod diogelwch" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "engh." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 5737ba66b8d..bc34810f97f 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Sikkerhedskode" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "eks." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Husk denne enhed for at gøre fremtidige indlogninger gnidningsløse" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Enhedsgodkendelse kræves. Vælg en godkendelsesmulighed nedenfor:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 352705ec281..91dfac2e7c0 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Sicherheitscode" }, + "cardNumber": { + "message": "Kartennummer" + }, "ex": { "message": "Bsp." }, @@ -3460,8 +3463,30 @@ "logInRequestSent": { "message": "Anfrage gesendet" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Anmeldestatus" + }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "Master-Passwort gespeichert" }, "exposedMasterPassword": { "message": "Kompromittiertes Master-Passwort" @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Dieses Gerät merken, um zukünftige Anmeldungen reibungslos zu gestalten" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Aktuelle Sitzung" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web-Tresor" + }, + "webApp": { + "message": "Web-App" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "Erste Anmeldung" + }, + "trusted": { + "message": "Vertrauenswürdig" + }, + "needsApproval": { + "message": "Benötigt Genehmigung" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Zugriffsversuch von $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Zugriff bestätigen" + }, + "denyAccess": { + "message": "Zugriff ablehnen" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Gerätetyp" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "Diese Anfrage ist nicht mehr gültig." + }, + "areYouTryingToAccessYourAccount": { + "message": "Versuchst du auf dein Konto zuzugreifen?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Anmeldung von $EMAIL$ auf $DEVICE$ bestätigt", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "Du hast einen Anmeldeversuch von einem anderen Gerät abgelehnt. Wenn du das wirklich warst, versuche dich erneut mit dem Gerät anzumelden." + }, + "loginRequestHasAlreadyExpired": { + "message": "Anmeldeanfrage ist bereits abgelaufen." + }, + "justNow": { + "message": "Gerade eben" + }, + "requestedXMinutesAgo": { + "message": "Vor $MINUTES$ Minuten angefordert", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Geräte-Genehmigung erforderlich. Wähle unten eine Genehmigungsoption aus:" }, @@ -4275,7 +4407,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Mehr über die Übereinstimmungs-Erkennung", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "$FIELD$, $VALUE$ kopieren", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 960de6670d5..014d17b74c8 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Κωδικός ασφαλείας" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "πχ." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Απομνημόνευση αυτής της συσκευής για την αυτόματες συνδέσεις στο μέλλον" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Απαιτείται έγκριση συσκευής. Επιλέξτε μια επιλογή έγκρισης παρακάτω:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index aab61eca30e..a17a48e95b8 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "e.g." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index eaf11a04e57..9f383c2f0e3 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "e.g." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 724d07f4404..35a28528f49 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Código de seguridad" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ej." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Solicitud enviada" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Se requiere aprobación del dispositivo. Seleccione una opción de aprobación a continuación:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copiar $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 99e7b72a524..daadcbf00e9 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Turvakood" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "nt." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Nõutav on seadme kinnitamine. Vali kinnitamise meetod alt:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 393f922463e..e5f836fcaae 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Segurtasun-kodea" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "adib." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index fb9380d5317..e551e96f74a 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "کد امنیتی" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "مثال." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "درخواست ارسال شد" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "این دستگاه را به خاطر بسپار تا ورودهای بعدی بدون مشکل انجام شود" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "تأیید دستگاه لازم است. یک روش تأیید انتخاب کنید:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "کپی $FIELD$، $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 85c47f2733b..22f2046bae3 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Turvakoodi (CVC/CVV)" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "esim." }, @@ -2214,7 +2217,7 @@ "message": "Käytä tätä salasanaa" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "Käytä tätä salalausetta" }, "useThisUsername": { "message": "Käytä tätä käyttäjätunnusta" @@ -2531,7 +2534,7 @@ "message": "Vaihda" }, "changePassword": { - "message": "Change password", + "message": "Vaihda salasana", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -3460,8 +3463,30 @@ "logInRequestSent": { "message": "Pyyntö lähetetty" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "Pääsalasana tallennettiin" }, "exposedMasterPassword": { "message": "Paljastunut pääsalasana" @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Muista tämä laite tehdäksesi tulevista kirjautumisista saumattomia" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Laitehyväksyntä vaaditaan. Valitse hyväksyntätapa alta:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopioi $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 4e3f94cb474..88610d6874c 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Kodigo ng Seguridad" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index efcc28ddcea..680a19f33cc 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Code de sécurité" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Demande envoyée" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Mémorisez cet appareil pour faciliter les futures connexions" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "L'approbation de l'appareil est requise. Sélectionnez une option d'approbation ci-dessous :" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copier $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 71841239698..559d0ca82b3 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Código de seguridade" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Lembrar este dispositivo para futuros inicios de sesión imperceptibles" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Aprobación de dispositivo requirida. Selecciona unha das seguintes opcións:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index b9a0a3dada4..e4d959785bf 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "קוד אבטחה" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "לדוגמא" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "בקשה נשלחה" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "זכור מכשיר זה כדי להפוך כניסות עתידיות לחלקות" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "נדרש אישור מכשיר. בחר אפשרות אישור למטה:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "העתק $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 74fc419d7f1..4fd2652d786 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security Code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 6434a09bcfb..07c3e20cd18 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Sigurnosni kôd" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "npr." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Zahtjev poslan" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Glavna lozinka promijenjena" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Zapamti ovaj uređaj kako bi buduće prijave bile brže" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Potrebno je odobriti uređaj. Odaberi metodu odobravanja:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopiraj $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 5391b266a93..228f7ef8c09 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Biztonsági Kód" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "példa:" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "A kérés elküldésre került." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "A mesterjelszó mentésre került." }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Emlékezés az eszközre, hogy zökkenőmentes legyen a jövőbeni bejelentkezés" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Az eszköz jóváhagyása szükséges. Válasszunk egy jóváhagyási lehetőséget lentebb:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "$FIELD$, $VALUE$ másolása", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index dc1ec15cede..07e094e10bd 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Kode Keamanan" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "mis." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Permintaan terkirim" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Ingat perangkat ini untuk membuat login berikutnya lebih lancar" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Persetujuan perangkat diperlukan. Pilih sebuah pilihan persetujuan berikut:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Salin $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index f4829ecabec..167cc51c0b1 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Codice di sicurezza" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "es." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Richiesta inviata" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Ricorda questo dispositivo per rendere immediati i futuri accessi" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Approvazione del dispositivo obbligatoria. Seleziona un'opzione di approvazione:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copia $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 783a9ff0eb1..49d22bd065f 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "セキュリティコード" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "例:" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "リクエストが送信されました" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "このデバイスを記憶して今後のログインをシームレスにする" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "デバイスの承認が必要です。以下から承認オプションを選択してください:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "$FIELD$ 「$VALUE$」 をコピー", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index efbf97e9a92..1e75805638c 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index d4e4498a322..c7a821de19b 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "ಭದ್ರತಾ ಕೋಡ್ " }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ಉದಾಹರಣೆ" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index d5eba5ccbd8..730d3eeda61 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "보안 코드" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "예)" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "향후 로그인을 원활하게 하기 위해 이 기기 기억하기" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "기기 승인이 필요합니다. 아래에서 승인 옵션을 선택하세요:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 33f3b0f0ed6..29815c9de82 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Apsaugos kodas" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "pvz." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Įrenginio patvirtinimas reikalingas. Pasirink patvirtinimo būdą toliau:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 2235baef2ab..9763801b773 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Drošības kods" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "piem." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Pieprasījums nosūtīts" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Galvenā parole saglabāta" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Atcerēties šo ierīci, lai nākotnes pieteikšanos padarītu plūdenāku" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Nepieciešams ierīces apstiprinājums. Zemāk jāatlasa apstiprinājuma iespēja:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Ievietot starpliktuvē $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 7c0050906b0..a39fe07b2c6 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "സുരക്ഷാ കോഡ്" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ഉദാഹരണം." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index feb3377a2f1..c79b8b322a7 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 59a8ca94c9d..01353cac5e0 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Sikkerhetskode" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "f.eks." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Forespørsel sendt" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopier $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 71134c7a33b..b67df127da0 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Beveiligingscode" }, + "cardNumber": { + "message": "kaartnummer" + }, "ex": { "message": "bijv." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Verzoek verzonden" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Apparaat" + }, + "loginStatus": { + "message": "Loginstatus" + }, "masterPasswordChanged": { "message": "Hoofdwachtwoord gewijzigd" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Onthoud dit apparaat om in het vervolg naadloos in te loggen" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "Dit verzoek is niet langer geldig." + }, + "areYouTryingToAccessYourAccount": { + "message": "Probeer je toegang te krijgen tot je account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Inloggen voor $EMAIL$ bevestigd op $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "Je hebt een inlogpoging vanaf een ander apparaat geweigerd. Als je dit toch echt zelf was, probeer dan opnieuw in te loggen met het apparaat." + }, + "loginRequestHasAlreadyExpired": { + "message": "Inlogverzoek is al verlopen." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Apparaattoestemming vereist. Kies een goedkeuringsoptie hieronder:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "$FIELD$, $VALUE$ kopiëren", + "copyFieldCipherName": { + "message": "$FIELD$, $CIPHERNAME$ kopiëren", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index b4b62d7968c..23221633916 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -659,7 +659,7 @@ "message": "Zweryfikuj tożsamość" }, "weDontRecognizeThisDevice": { - "message": "Nie rozpoznajemy tego urządzenia. Wpisz kod wysłany na Twój e-mail, aby zweryfikować tożsamość." + "message": "Nie rozpoznajemy tego urządzenia. Wpisz kod wysłany na adres e-mail, aby zweryfikować swoją tożsamość." }, "continueLoggingIn": { "message": "Kontynuuj logowanie" @@ -1213,17 +1213,17 @@ "message": "Pokaż opcje w menu kontekstowym" }, "contextMenuItemDesc": { - "message": "Użyj drugiego kliknięcia, aby uzyskać dostęp do generowania haseł i pasujących danych logowania do witryny." + "message": "Użyj drugiego kliknięcia, aby uzyskać dostęp do generatora hasła i pasujących danych logowania." }, "contextMenuItemDescAlt": { - "message": "Użyj drugiego kliknięcia, aby uzyskać dostęp do generowania haseł i pasujących danych logowania do witryny. Dotyczy wszystkich zalogowanych kont." + "message": "Użyj drugiego kliknięcia, aby uzyskać dostęp do generatora hasła i pasujących danych logowania. Dotyczy wszystkich zalogowanych kont." }, "defaultUriMatchDetection": { "message": "Domyślne wykrywanie dopasowania", "description": "Default URI match detection for autofill." }, "defaultUriMatchDetectionDesc": { - "message": "Wybierz domyślny sposób wykrywania dopasowania adresów dla czynności takich jak autouzupełnianie." + "message": "Wybierz domyślne wykrywanie dopasowania dla autouzupełniania." }, "theme": { "message": "Motyw" @@ -1252,7 +1252,7 @@ "message": "Format pliku" }, "fileEncryptedExportWarningDesc": { - "message": "Plik będzie chroniony hasłem, które będzie wymagane do odszyfrowania pliku." + "message": "Plik zostanie zaszyfrowany hasłem." }, "filePassword": { "message": "Hasło pliku" @@ -1296,7 +1296,7 @@ "message": "Klucze szyfrowania konta są unikalne dla każdego użytkownika Bitwarden, więc nie możesz zaimportować zaszyfrowanego pliku eksportu na inne konto." }, "exportMasterPassword": { - "message": "Wpisz hasło główne, aby wyeksportować dane z sejfu." + "message": "Wpisz hasło główne, aby wyeksportować dane sejfu." }, "shared": { "message": "Udostępnione" @@ -1372,7 +1372,7 @@ "message": "Funkcja jest niedostępna" }, "legacyEncryptionUnsupported": { - "message": "Starsze szyfrowanie nie jest już obsługiwane. Skontaktuj się z pomocą techniczną, aby odzyskać swoje konto." + "message": "Starsze szyfrowanie nie jest już obsługiwane. Skontaktuj się z pomocą techniczną, aby odzyskać konto." }, "premiumMembership": { "message": "Konto premium" @@ -1490,7 +1490,7 @@ "message": "Użyj kodu odzyskiwania" }, "insertU2f": { - "message": "Włóż klucz bezpieczeństwa do portu USB komputera. Jeśli klucz posiada przycisk, dotknij go." + "message": "Włóż klucz bezpieczeństwa do portu USB urządzenia. Jeśli klucz ma przycisk, dotknij go." }, "openInNewTab": { "message": "Otwórz w nowej karcie" @@ -1502,7 +1502,7 @@ "message": "Odczytaj klucz bezpieczeństwa" }, "awaitingSecurityKeyInteraction": { - "message": "Oczekiwanie na interakcję z kluczem bezpieczeństwa..." + "message": "Oczekiwanie na klucz bezpieczeństwa..." }, "loginUnavailable": { "message": "Logowanie jest niedostępne" @@ -1550,7 +1550,7 @@ "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Użyj dowolnego klucza bezpieczeństwa WebAuthn, aby uzyskać dostęp do swojego konta." + "message": "Użyj dowolnego klucza bezpieczeństwa WebAuthn, aby uzyskać dostęp do konta." }, "emailTitle": { "message": "Adres e-mail" @@ -1606,7 +1606,7 @@ "message": "Sugestie autouzupełniania" }, "autofillSpotlightTitle": { - "message": "Łatwe znajdowanie sugestii autouzupełniania" + "message": "Łatwe wyszukiwanie sugestii autouzupełniania" }, "autofillSpotlightDesc": { "message": "Wyłącz ustawienia autouzupełniania swojej przeglądarki, aby nie kolidowały z Bitwarden." @@ -1639,7 +1639,7 @@ "message": "Dotyczy wszystkich zalogowanych kont." }, "turnOffBrowserBuiltInPasswordManagerSettings": { - "message": "Wyłącz wbudowany w przeglądarkę menedżer haseł, aby uniknąć konfliktów." + "message": "Wyłącz menedżer haseł przeglądarki, aby uniknąć konfliktów." }, "turnOffBrowserBuiltInPasswordManagerSettingsLink": { "message": "Edytuj ustawienia przeglądarki." @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Kod zabezpieczający" }, + "cardNumber": { + "message": "numer karty" + }, "ex": { "message": "np." }, @@ -1977,7 +1980,7 @@ "message": "Kolekcje" }, "nCollections": { - "message": "Kolekcje: $COUNT$", + "message": "Kolekcje ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -2646,13 +2649,13 @@ "description": "Description of the update in Bitwarden slide on the at-risk password page carousel" }, "updateInBitwardenSlideImgAltPeriod": { - "message": "Ilustracja powiadomienia Bitwardena, skłaniająca użytkownika do zaktualizowania danych logowania." + "message": "Ilustracja powiadomienia Bitwarden, zachęcająca użytkownika do zaktualizowania danych logowania." }, "turnOnAutofill": { "message": "Włącz autouzupełnianie" }, "turnedOnAutofill": { - "message": "Włączono autouzupełnianie" + "message": "Autouzupełnianie zostało włączone" }, "dismiss": { "message": "Odrzuć" @@ -2902,7 +2905,7 @@ "message": "Data i czas usunięcia są wymagane." }, "dateParsingError": { - "message": "Wystąpił błąd podczas zapisywania daty usunięcia i wygaśnięcia." + "message": "Wystąpił błąd podczas zapisywania dat usunięcia i wygaśnięcia." }, "hideYourEmail": { "message": "Ukryj mój adres e-mail przed odbiorcami." @@ -3136,7 +3139,7 @@ "message": "Błąd odszyfrowywania" }, "couldNotDecryptVaultItemsBelow": { - "message": "Bitwarden nie mógł odszyfrować elementów sejfu wymienionych poniżej." + "message": "Bitwarden nie mógł odszyfrować poniższych elementów sejfu." }, "contactCSToAvoidDataLossPart1": { "message": "Skontaktuj się z działem obsługi klienta,", @@ -3449,7 +3452,7 @@ "message": "Powiadomienie zostało wysłane na urządzenie" }, "youWillBeNotifiedOnceTheRequestIsApproved": { - "message": "Zostaniesz powiadomiony po zatwierdzeniu prośby" + "message": "Zostaniesz powiadomiony po potwierdzeniu" }, "needAnotherOptionV1": { "message": "Potrzebujesz innej opcji?" @@ -3458,7 +3461,29 @@ "message": "Logowanie rozpoczęte" }, "logInRequestSent": { - "message": "Żądanie wysłane" + "message": "Prośba została wysłana" + }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Urządzenie" + }, + "loginStatus": { + "message": "Login status" }, "masterPasswordChanged": { "message": "Hasło główne zostało zapisane" @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Zapamiętaj to urządzenie, aby przyszłe logowania były bezproblemowe" }, + "manageDevices": { + "message": "Zarządzaj urządzeniami" + }, + "currentSession": { + "message": "Obecna sesja" + }, + "mobile": { + "message": "Telefon", + "description": "Mobile app" + }, + "extension": { + "message": "Rozszerzenie", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Komputer", + "description": "Desktop app" + }, + "webVault": { + "message": "Sejf internetowy" + }, + "webApp": { + "message": "Aplikacja internetowa" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "Pierwsze logowanie" + }, + "trusted": { + "message": "Zaufane" + }, + "needsApproval": { + "message": "Wymagane potwierdzenie" + }, + "devices": { + "message": "Urządzenia" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Potwierdź dostęp" + }, + "denyAccess": { + "message": "Odmów dostępu" + }, + "time": { + "message": "Czas" + }, + "deviceType": { + "message": "Rodzaj urządzenia" + }, + "loginRequest": { + "message": "Żądanie logowania" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Teraz" + }, + "requestedXMinutesAgo": { + "message": "Poproszono $MINUTES$ min temu", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Wymagane zatwierdzenie urządzenia. Wybierz opcję zatwierdzenia poniżej:" }, @@ -3563,19 +3695,19 @@ "message": "Wymagane zatwierdzenie urządzenia" }, "selectAnApprovalOptionBelow": { - "message": "Wybierz opcję zatwierdzenia poniżej" + "message": "Wybierz opcję potwierdzenia" }, "rememberThisDevice": { "message": "Zapamiętaj urządzenie" }, "uncheckIfPublicDevice": { - "message": "Odznacz, jeśli używasz publicznego urządzenia" + "message": "Wyłącz na obcych urządzeniach" }, "approveFromYourOtherDevice": { - "message": "Zatwierdź z innego twojego urządzenia" + "message": "Potwierdź za pomocą innego urządzenia" }, "requestAdminApproval": { - "message": "Poproś administratora o zatwierdzenie" + "message": "Poproś administratora o potwierdzenie" }, "unableToCompleteLogin": { "message": "Nie można ukończyć logowania" @@ -3624,10 +3756,10 @@ "message": "Konto zostało utworzone!" }, "adminApprovalRequested": { - "message": "Poproszono administratora o zatwierdzenie" + "message": "Poproszono administratora o potwierdzenie" }, "adminApprovalRequestSentToAdmins": { - "message": "Twoja prośba została wysłana do Twojego administratora." + "message": "Prośba została wysłana do administratora." }, "troubleLoggingIn": { "message": "Problem z logowaniem?" @@ -4307,7 +4439,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the browser's keyboard shortcut settings page" }, "overrideDefaultBrowserAutofillTitle": { - "message": "Czy ustawić Bitwarden jako domyślny menadżer haseł?", + "message": "Ustawić Bitwarden jako domyślny menadżer haseł?", "description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutofillDescription": { @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopiuj $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Kopiuj $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, @@ -5455,7 +5587,7 @@ "message": "Nie masz uprawnień do przeglądania tej strony. Spróbuj zalogować się na inne konto." }, "wasmNotSupported": { - "message": "Zestaw WebAssembly nie jest obsługiwany w przeglądarce lub nie jest włączony. Do korzystania z aplikacji Bitwarden wymagany jest zestaw WebAssembre.", + "message": "WebAssembly nie jest obsługiwany w przeglądarce lub jest wyłączony. WebAssembly jest wymagany do korzystania z aplikacji Bitwarden.", "description": "'WebAssembly' is a technical term and should not be translated." } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 4d0e4148782..7d5509a628b 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Código de Segurança" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Pedido enviado" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Lembrar deste dispositivo para permanecer conectado" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Aprovação do dispositivo necessária. Selecione uma opção de aprovação abaixo:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copiar $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 8f06014b6b2..b31a7797df3 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Código de segurança" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Pedido enviado" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Palavra-passe mestra guardada" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Memorizar este dispositivo para facilitar futuros inícios de sessão" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "É necessária a aprovação do dispositivo. Selecione uma opção de aprovação abaixo:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copiar $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 02fc0054e73..7f54640af25 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Cod de securitate" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Este necesară aprobarea dispozitivului. Selectați o opțiune de autorizare de mai jos:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 2c9aa0f4d45..31e331fadad 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Код безопасности" }, + "cardNumber": { + "message": "номер карты" + }, "ex": { "message": "напр." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Запрос отправлен" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Запрос входа для $EMAIL$ на $DEVICE$ одобрен", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Вы отклонили попытку авторизации с другого устройства. Если это были вы, попробуйте авторизоваться с этого устройства еще раз." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Мастер-пароль сохранен" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Запомнить это устройство, чтобы в будущем авторизовываться быстрее" }, + "manageDevices": { + "message": "Управление устройствами" + }, + "currentSession": { + "message": "Текущая сессия" + }, + "mobile": { + "message": "Мобильный", + "description": "Mobile app" + }, + "extension": { + "message": "Расширение", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Компьютер", + "description": "Desktop app" + }, + "webVault": { + "message": "Веб-хранилище" + }, + "webApp": { + "message": "Веб-приложение" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Запрос в ожидании" + }, + "firstLogin": { + "message": "Первый вход" + }, + "trusted": { + "message": "Доверенный" + }, + "needsApproval": { + "message": "Требуется одобрение" + }, + "devices": { + "message": "Устройства" + }, + "accessAttemptBy": { + "message": "Попытка доступа $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Подтвердить доступ" + }, + "denyAccess": { + "message": "Отказать в доступе" + }, + "time": { + "message": "Время" + }, + "deviceType": { + "message": "Тип устройства" + }, + "loginRequest": { + "message": "Запрос на вход" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Только что" + }, + "requestedXMinutesAgo": { + "message": "Запрошено $MINUTES$ минут назад", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Требуется одобрение устройства. Выберите вариант ниже:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Скопировать $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Копировать $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index aa88e12ba39..13e6c2522bf 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "ආරක්ෂක කේතය" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "හිටපු." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 356617ea25c..7285399af73 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Bezpečnostný kód" }, + "cardNumber": { + "message": "číslo karty" + }, "ex": { "message": "napr." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Požiadavka bola odoslaná" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Potvrdené prihlásenie pre $EMAIL$ na $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli vy, skúste sa prihlásiť pomocou zariadenia znova." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Hlavné heslo uložené" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Zapamätať si toto zariadenie, pre budúce bezproblémové prihlásenie" }, + "manageDevices": { + "message": "Spravovať zariadenia" + }, + "currentSession": { + "message": "Aktuálna relácia" + }, + "mobile": { + "message": "Mobil", + "description": "Mobile app" + }, + "extension": { + "message": "Rozšírenie", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Počítač", + "description": "Desktop app" + }, + "webVault": { + "message": "Webový trezor" + }, + "webApp": { + "message": "Webová aplikácia" + }, + "cli": { + "message": "Príkazový riadok (CLI)" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Žiadosť čaká na spracovanie" + }, + "firstLogin": { + "message": "Prvé prihlásenie" + }, + "trusted": { + "message": "Dôveryhodné" + }, + "needsApproval": { + "message": "Potrebuje súhlas" + }, + "devices": { + "message": "Zariadenia" + }, + "accessAttemptBy": { + "message": "Pokus o prístup z $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Potvrdiť prístup" + }, + "denyAccess": { + "message": "Zamietnuť prístup" + }, + "time": { + "message": "Čas" + }, + "deviceType": { + "message": "Typ zariadenia" + }, + "loginRequest": { + "message": "Žiadosť o prihlásenie" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Práve teraz" + }, + "requestedXMinutesAgo": { + "message": "Vyžiadané pred $MINUTES$ min.", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Vyžaduje sa schválenie zariadenia. Vyberte možnosť schválenia nižšie:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopírovať $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Kopírovať $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 72f058254e4..397b7be54e8 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Varnostna koda" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "npr." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 4ac75bde569..f68d0b97447 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Сигурносни код" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "нпр." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Захтев је послат" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Главна лозинка сачувана" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Запамтити овај уређај да би будуће пријаве биле беспрекорне" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Потребно је одобрење уређаја. Изаберите опцију одобрења испод:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Копирај $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index ff20cdd2ed9..725497cc26b 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Säkerhetskod" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "t. ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Begäran skickad" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Inloggningsbegäran godkänd för $EMAIL$ på $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Du nekade ett inloggningsförsök från en annan enhet. Om det var du, försök att logga in med enheten igen." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Huvudlösenordet sparades" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Kom ihåg den här enheten för att göra framtida inloggningar smidiga" }, + "manageDevices": { + "message": "Hantera enheter" + }, + "currentSession": { + "message": "Aktuell session" + }, + "mobile": { + "message": "Mobil", + "description": "Mobile app" + }, + "extension": { + "message": "Tillägg", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Skrivbord", + "description": "Desktop app" + }, + "webVault": { + "message": "Webbvalv" + }, + "webApp": { + "message": "Webbapp" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Förfrågning väntar" + }, + "firstLogin": { + "message": "Första inloggningen" + }, + "trusted": { + "message": "Betrodd" + }, + "needsApproval": { + "message": "Kräver godkännande" + }, + "devices": { + "message": "Enheter" + }, + "accessAttemptBy": { + "message": "Åtkomstförsök av $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Bekräfta åtkomst" + }, + "denyAccess": { + "message": "Neka åtkomst" + }, + "time": { + "message": "Tid" + }, + "deviceType": { + "message": "Enhetstyp" + }, + "loginRequest": { + "message": "Begäran om inloggning" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just nu" + }, + "requestedXMinutesAgo": { + "message": "Begärdes för $MINUTES$ minuter sedan", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Godkännande av enhet krävs. Välj ett alternativ för godkännande nedan:" }, @@ -4075,10 +4207,10 @@ "message": "Inloggad!" }, "passkeyNotCopied": { - "message": "Lösennyckeln kommer inte kopieras" + "message": "Inloggningsnyckeln kommer inte kopieras" }, "passkeyNotCopiedAlert": { - "message": "Lösennyckeln kommer inte att kopieras till det klonade objektet. Vill du fortsätta klona det här objektet?" + "message": "Inloggningsnyckeln kommer inte att kopieras till det klonade objektet. Vill du fortsätta klona det här objektet?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { "message": "Verifiering krävs av den initierande webbplatsen. Denna funktion är ännu inte implementerad för konton utan huvudlösenord." @@ -4087,7 +4219,7 @@ "message": "Logga in med nyckel?" }, "passkeyAlreadyExists": { - "message": "En lösennyckel finns redan för detta program." + "message": "En inloggningsnyckel finns redan för detta program." }, "noPasskeysFoundForThisApplication": { "message": "Inga lösennycklar hittades för detta program." @@ -4111,25 +4243,25 @@ "message": "Spara nyckel som ny inloggning" }, "chooseCipherForPasskeySave": { - "message": "Välj en inloggning som du vill spara nyckeln till" + "message": "Välj en inloggning som du vill spara inloggningsnyckeln till" }, "chooseCipherForPasskeyAuth": { - "message": "Välj en lösenordskod att logga in med" + "message": "Välj en inloggningsnyckel att logga in med" }, "passkeyItem": { - "message": "Lösennyckelobjekt" + "message": "Inloggningsnyckelsobjekt" }, "overwritePasskey": { - "message": "Skriv över lösennyckel?" + "message": "Skriv över inloggningsnyckel?" }, "overwritePasskeyAlert": { - "message": "Detta objekt innehåller redan en lösennyckel. Är du säker på att du vill skriva över nuvarande lösennyckeln?" + "message": "Detta objekt innehåller redan en inloggningsnyckel. Är du säker på att du vill skriva över nuvarande inloggningsnyckel?" }, "featureNotSupported": { "message": "Funktionen stöds ännu inte" }, "yourPasskeyIsLocked": { - "message": "Autentisering krävs för att använda lösennyckel. Verifiera din identitet för att fortsätta." + "message": "Autentisering krävs för att använda inloggningsnyckel. Verifiera din identitet för att fortsätta." }, "multifactorAuthenticationCancelled": { "message": "Flerfaktorsautentisering avbruten" @@ -4354,10 +4486,10 @@ "message": "Lyckades" }, "removePasskey": { - "message": "Ta bort passkey" + "message": "Ta bort inloggningsnyckel" }, "passkeyRemoved": { - "message": "Passkey borttagen" + "message": "Inloggningsnyckel borttagen" }, "autofillSuggestions": { "message": "Förslag för autofyll" @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopiera $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index ecc7da63e79..9a6d9a4d316 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index c085b7557e0..49515eb1c64 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Security Code" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "ex." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Request sent" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Remember this device to make future logins seamless" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 5dae8dfcdc4..cd7c8d3f0b6 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Güvenlik kodu" }, + "cardNumber": { + "message": "kart numarası" + }, "ex": { "message": "örn." }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "İstek gönderildi" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "$DEVICE$ cihazında $EMAIL$ girişi onaylandı", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Başka bir cihazdan giriş isteğini reddettiniz. Yanlışlıkla yaptıysanız aynı cihazdan yeniden giriş yapmayı deneyin." + }, + "device": { + "message": "Cihaz" + }, + "loginStatus": { + "message": "Oturum açma durumu" + }, "masterPasswordChanged": { "message": "Ana parola kaydedildi" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Sonraki girişleri kolaylaştırmak için bu cihazı hatırla" }, + "manageDevices": { + "message": "Cihazları yönet" + }, + "currentSession": { + "message": "Geçerli oturum" + }, + "mobile": { + "message": "Mobil", + "description": "Mobile app" + }, + "extension": { + "message": "Uzantı", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Masaüstü", + "description": "Desktop app" + }, + "webVault": { + "message": "Web kasası" + }, + "webApp": { + "message": "Web uygulaması" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "İstek bekliyor" + }, + "firstLogin": { + "message": "İlk giriş" + }, + "trusted": { + "message": "Güvenilen" + }, + "needsApproval": { + "message": "Onay gerekiyor" + }, + "devices": { + "message": "Cihazlar" + }, + "accessAttemptBy": { + "message": "$EMAIL$ erişim denemesi", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Erişimi onayla" + }, + "denyAccess": { + "message": "Erişimi reddet" + }, + "time": { + "message": "Tarih" + }, + "deviceType": { + "message": "Cihaz türü" + }, + "loginRequest": { + "message": "Giriş isteği" + }, + "thisRequestIsNoLongerValid": { + "message": "Bu istek artık geçerli değil." + }, + "areYouTryingToAccessYourAccount": { + "message": "Hesabınıza erişmeye mi çalışıyorsunuz?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "$DEVICE$ cihazında $EMAIL$ girişi onaylandı", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "Başka bir cihazdan giriş isteğini reddettiniz. Yanlışlıkla yaptıysanız aynı cihazdan yeniden giriş yapmayı deneyin." + }, + "loginRequestHasAlreadyExpired": { + "message": "Giriş isteğinin süresi doldu." + }, + "justNow": { + "message": "Az önce" + }, + "requestedXMinutesAgo": { + "message": "$MINUTES$ dakika önce istendi", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Cihaz onayı gerekiyor. Lütfen onay yönteminizi seçin:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Kopyala: $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Kopyala: $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 3d25e982642..083d89fbd12 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Код безпеки" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "зразок" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Запит надіслано" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Головний пароль збережено" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Запам'ятайте цей пристрій, щоб спростити майбутні входи в систему" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Необхідне підтвердження пристрою. Виберіть варіант підтвердження нижче:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Копіювати $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index e2752827221..7aa8348c6b8 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "Mã bảo mật" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "Ví dụ:" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "Đã gửi yêu cầu" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Đã lưu mật khẩu chính" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "Nhớ thiết bị này để đăng nhập dễ dàng trong tương lai" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "Yêu cầu phê duyệt thiết bị. Chọn một tuỳ chọn phê duyệt bên dưới:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Sao chép $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 9ee8fd5a48f..9b7d460261a 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "安全码" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "例如" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "请求已发送" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "主密码已保存" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "记住此设备以便将来无缝登录" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "需要设备批准。请在下面选择一个批准选项:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "复制 $FIELD$,$VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 7403ad53705..b41b9271c75 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -1829,6 +1829,9 @@ "securityCode": { "message": "安全代碼" }, + "cardNumber": { + "message": "card number" + }, "ex": { "message": "例如" }, @@ -3460,6 +3463,28 @@ "logInRequestSent": { "message": "已傳送請求" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, + "device": { + "message": "Device" + }, + "loginStatus": { + "message": "Login status" + }, "masterPasswordChanged": { "message": "Master password saved" }, @@ -3556,6 +3581,113 @@ "rememberThisDeviceToMakeFutureLoginsSeamless": { "message": "記住此裝置來讓未來的登入體驗更簡易" }, + "manageDevices": { + "message": "Manage devices" + }, + "currentSession": { + "message": "Current session" + }, + "mobile": { + "message": "Mobile", + "description": "Mobile app" + }, + "extension": { + "message": "Extension", + "description": "Browser extension/addon" + }, + "desktop": { + "message": "Desktop", + "description": "Desktop app" + }, + "webVault": { + "message": "Web vault" + }, + "webApp": { + "message": "Web app" + }, + "cli": { + "message": "CLI" + }, + "sdk": { + "message": "SDK", + "description": "Software Development Kit" + }, + "requestPending": { + "message": "Request pending" + }, + "firstLogin": { + "message": "First login" + }, + "trusted": { + "message": "Trusted" + }, + "needsApproval": { + "message": "Needs approval" + }, + "devices": { + "message": "Devices" + }, + "accessAttemptBy": { + "message": "Access attempt by $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "confirmAccess": { + "message": "Confirm access" + }, + "denyAccess": { + "message": "Deny access" + }, + "time": { + "message": "Time" + }, + "deviceType": { + "message": "Device Type" + }, + "loginRequest": { + "message": "Login request" + }, + "thisRequestIsNoLongerValid": { + "message": "This request is no longer valid." + }, + "areYouTryingToAccessYourAccount": { + "message": "Are you trying to access your account?" + }, + "logInConfirmedForEmailOnDevice": { + "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "iOS" + } + } + }, + "youDeniedALogInAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + }, + "loginRequestHasAlreadyExpired": { + "message": "Login request has already expired." + }, + "justNow": { + "message": "Just now" + }, + "requestedXMinutesAgo": { + "message": "Requested $MINUTES$ minutes ago", + "placeholders": { + "minutes": { + "content": "$1", + "example": "5" + } + } + }, "deviceApprovalRequired": { "message": "裝置需要取得核准。請在下面選擇一個核准選項:" }, @@ -4465,17 +4597,17 @@ } } }, - "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { "content": "$1", "example": "Username" }, - "value": { + "ciphername": { "content": "$2", - "example": "Foo" + "example": "Login Item" } } }, diff --git a/apps/browser/store/locales/sv/copy.resx b/apps/browser/store/locales/sv/copy.resx index d03c7fc808d..c37095ec167 100644 --- a/apps/browser/store/locales/sv/copy.resx +++ b/apps/browser/store/locales/sv/copy.resx @@ -165,7 +165,7 @@ Applikationer för flera plattformar Säkra och dela känslig data i ditt Bitwarden Vault från vilken webbläsare, mobil enhet eller stationärt operativsystem som helst, och mycket mer. Bitwarden säkrar mer än bara lösenord -End-to-end krypterade lösningar för hantering av referenser från Bitwarden gör det möjligt för organisationer att säkra allt, inklusive utvecklarhemligheter och passkey-upplevelser. Besök Bitwarden.com för att lära dig mer om Bitwarden Secrets Manager och Bitwarden Passwordless.dev! +End-to-end krypterade lösningar för hantering av referenser från Bitwarden gör det möjligt för organisationer att säkra allt, inklusive utvecklarhemligheter och upplevelser med inloggningsnycklar. Besök Bitwarden.com för att lära dig mer om Bitwarden Secrets Manager och Bitwarden Passwordless.dev! </value> </data> <data name="AssetTitle" xml:space="preserve"> From 80a6268e81b7f4a6a9be61d0b0250644eb7f2a04 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:45:28 +0200 Subject: [PATCH 19/54] Autosync the updated translations (#15673) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 42 ++++++++ apps/web/src/locales/ar/messages.json | 42 ++++++++ apps/web/src/locales/az/messages.json | 46 ++++++++- apps/web/src/locales/be/messages.json | 42 ++++++++ apps/web/src/locales/bg/messages.json | 42 ++++++++ apps/web/src/locales/bn/messages.json | 42 ++++++++ apps/web/src/locales/bs/messages.json | 42 ++++++++ apps/web/src/locales/ca/messages.json | 42 ++++++++ apps/web/src/locales/cs/messages.json | 42 ++++++++ apps/web/src/locales/cy/messages.json | 42 ++++++++ apps/web/src/locales/da/messages.json | 42 ++++++++ apps/web/src/locales/de/messages.json | 66 ++++++++++--- apps/web/src/locales/el/messages.json | 42 ++++++++ apps/web/src/locales/en_GB/messages.json | 42 ++++++++ apps/web/src/locales/en_IN/messages.json | 42 ++++++++ apps/web/src/locales/eo/messages.json | 42 ++++++++ apps/web/src/locales/es/messages.json | 42 ++++++++ apps/web/src/locales/et/messages.json | 42 ++++++++ apps/web/src/locales/eu/messages.json | 42 ++++++++ apps/web/src/locales/fa/messages.json | 42 ++++++++ apps/web/src/locales/fi/messages.json | 42 ++++++++ apps/web/src/locales/fil/messages.json | 42 ++++++++ apps/web/src/locales/fr/messages.json | 42 ++++++++ apps/web/src/locales/gl/messages.json | 42 ++++++++ apps/web/src/locales/he/messages.json | 42 ++++++++ apps/web/src/locales/hi/messages.json | 42 ++++++++ apps/web/src/locales/hr/messages.json | 42 ++++++++ apps/web/src/locales/hu/messages.json | 42 ++++++++ apps/web/src/locales/id/messages.json | 42 ++++++++ apps/web/src/locales/it/messages.json | 42 ++++++++ apps/web/src/locales/ja/messages.json | 42 ++++++++ apps/web/src/locales/ka/messages.json | 42 ++++++++ apps/web/src/locales/km/messages.json | 42 ++++++++ apps/web/src/locales/kn/messages.json | 42 ++++++++ apps/web/src/locales/ko/messages.json | 42 ++++++++ apps/web/src/locales/lv/messages.json | 42 ++++++++ apps/web/src/locales/ml/messages.json | 42 ++++++++ apps/web/src/locales/mr/messages.json | 42 ++++++++ apps/web/src/locales/my/messages.json | 42 ++++++++ apps/web/src/locales/nb/messages.json | 42 ++++++++ apps/web/src/locales/ne/messages.json | 42 ++++++++ apps/web/src/locales/nl/messages.json | 42 ++++++++ apps/web/src/locales/nn/messages.json | 42 ++++++++ apps/web/src/locales/or/messages.json | 42 ++++++++ apps/web/src/locales/pl/messages.json | 42 ++++++++ apps/web/src/locales/pt_BR/messages.json | 42 ++++++++ apps/web/src/locales/pt_PT/messages.json | 46 ++++++++- apps/web/src/locales/ro/messages.json | 42 ++++++++ apps/web/src/locales/ru/messages.json | 44 ++++++++- apps/web/src/locales/si/messages.json | 42 ++++++++ apps/web/src/locales/sk/messages.json | 120 +++++++++++++++-------- apps/web/src/locales/sl/messages.json | 42 ++++++++ apps/web/src/locales/sr_CS/messages.json | 42 ++++++++ apps/web/src/locales/sr_CY/messages.json | 42 ++++++++ apps/web/src/locales/sv/messages.json | 86 +++++++++++----- apps/web/src/locales/te/messages.json | 42 ++++++++ apps/web/src/locales/th/messages.json | 42 ++++++++ apps/web/src/locales/tr/messages.json | 42 ++++++++ apps/web/src/locales/uk/messages.json | 42 ++++++++ apps/web/src/locales/vi/messages.json | 42 ++++++++ apps/web/src/locales/zh_CN/messages.json | 42 ++++++++ apps/web/src/locales/zh_TW/messages.json | 42 ++++++++ 62 files changed, 2682 insertions(+), 78 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 75c9275b288..5cd8d087d15 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Beveiligde nota" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ek" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webkluis" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 1c103219fa1..7642fa69d34 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "ملاحظة سرية" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "مفتاح بروتوكول النقل الآمن" }, @@ -861,6 +864,23 @@ "copyName": { "message": "نسخ الاسم" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "أنا" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "قبو الويب" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index a455efa3230..4bcb8431331 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Güvənli qeyd" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH açarı" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Adı kopyala" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Mən" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Veb seyf" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Başqa bir cihazdan giriş cəhdinə rədd cavabı verdiniz. Bu həqiqətən siz idinizsə, cihazla yenidən giriş etməyə çalışın." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Giriş tələbinin müddəti artıq bitib." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Giriş tələbini incələ" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Ödənişsiz sınaq müddətiniz $COUNT$ günə bitir.", "placeholders": { @@ -8939,11 +8981,11 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Qabaqcıl seçimlər", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Xəbərdarlıq", "description": "Warning (should maintain locale-relevant capitalization)" }, "maintainYourSubscription": { diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 40875670107..d07df39790c 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Абароненая нататка" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Я" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Вэб-сховішча" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 77ea169bb2b..4bcfd56e95c 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Защитена бележка" }, + "typeNote": { + "message": "Бележка" + }, "typeSshKey": { "message": "SSH ключ" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Копиране на името" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Аз" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Трезор по уеб" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "ИКР" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Вие отказахте опит за вписване от друго устройство. Ако това наистина сте били Вие, опитайте да се впишете от устройството отново." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Заявката за вписване вече е изтекла." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Преглед на заявката за вписване" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Вашият безплатен пробен период приключва след $COUNT$ дни.", "placeholders": { diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 66aa1b9a62e..0b0573c244d 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "সুরক্ষিত নোট" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "ওয়েব ভল্ট" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 9d9bece2b6e..02ed711cb5f 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sigurna bilješka" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ja" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 118af8c66fb..782ecc69b67 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota segura" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Clau SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copia el nom" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Jo" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Caixa forta web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 51c04fd9260..6ae11489480 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Zabezpečená poznámka" }, + "typeNote": { + "message": "Poznámka" + }, "typeSshKey": { "message": "SSH klíč" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopírovat název" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Já" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webový trezor" }, + "webApp": { + "message": "Webová aplikace" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Pokus o přihlášení byl zamítnut z jiného zařízení. Pokud jste to opravdu Vy, zkuste se znovu přihlásit do zařízení." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Požadavek na přihlášení byl schválen pro $EMAIL$ na $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Pokus o přihlášení byl zamítnut z jiného zařízení. Pokud jste to Vy, zkuste se znovu přihlásit do zařízení." + }, "loginRequestHasAlreadyExpired": { "message": "Požadavek na přihlášení již vypršel." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Podívat se na žádost o přihlášení" }, + "loginRequest": { + "message": "Požadavek na přihlášení" + }, "freeTrialEndPromptCount": { "message": "Vaše zkušební doba končí za $COUNT$ dnů.", "placeholders": { diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 012dcac1fc9..4089f11392c 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 3dfde9ee756..1d2ebcaca0f 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sikret notat" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH-nøgle" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopiér navn" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Mig" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web-boks" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Du nægtede et loginforsøg fra en anden enhed. Hvis dette virkelig var dig, prøv at logge ind med enheden igen." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login-anmodning er allerede udløbet." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Den gratis prøveperiode slutter om $COUNT$ dage.", "placeholders": { diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 5ce3b6883bb..3b0d8cccf97 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sichere Notiz" }, + "typeNote": { + "message": "Notiz" + }, "typeSshKey": { "message": "SSH-Schlüssel" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Name kopieren" }, + "cardNumber": { + "message": "Kartennummer" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ich" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web-Tresor" }, + "webApp": { + "message": "Web-App" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Du hast einen Anmeldeversuch von einem anderen Gerät abgelehnt. Wenn du das wirklich warst, versuche dich erneut mit dem Gerät anzumelden." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Du hast einen Anmeldeversuch von einem anderen Gerät abgelehnt. Wenn du das wirklich warst, versuche dich erneut mit dem Gerät anzumelden." + }, "loginRequestHasAlreadyExpired": { "message": "Anmeldeanfrage ist bereits abgelaufen." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Anmeldeanfrage überprüfen" }, + "loginRequest": { + "message": "Anmeldungsanfrage" + }, "freeTrialEndPromptCount": { "message": "Deine kostenlose Testversion endet in $COUNT$ Tagen.", "placeholders": { @@ -6077,7 +6119,7 @@ "message": "Master-Passwort aktualisieren" }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Ändere dein Master-Passwort, um die Kontowiederherstellung abzuschließen." }, "updateMasterPasswordSubtitle": { "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." @@ -8463,7 +8505,7 @@ "message": "Admin-Genehmigung anfragen" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { "message": "You need to log in on a trusted device or ask your administrator to assign you a password." @@ -8935,7 +8977,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Mehr über die Übereinstimmungs-Erkennung", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -8943,7 +8985,7 @@ "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Warnung", "description": "Warning (should maintain locale-relevant capitalization)" }, "maintainYourSubscription": { @@ -10758,20 +10800,20 @@ "message": "Skip to web app" }, "bitwardenExtensionInstalled": { - "message": "Bitwarden extension installed!" + "message": "Bitwarden-Erweiterung installiert!" }, "openExtensionToAutofill": { "message": "Open the extension to log in and start autofilling." }, "openBitwardenExtension": { - "message": "Open Bitwarden extension" + "message": "Bitwarden-Erweiterung öffnen" }, "gettingStartedWithBitwardenPart1": { "message": "For tips on getting started with Bitwarden visit the", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart2": { - "message": "Learning Center", + "message": "Lernzentrum", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart3": { @@ -10808,19 +10850,19 @@ "description": "Error message shown when trying to add credit to a trialing organization without a billing address." }, "billingAddress": { - "message": "Billing address" + "message": "Rechnungsadresse" }, "addBillingAddress": { - "message": "Add billing address" + "message": "Rechnungsadresse hinzufügen" }, "editBillingAddress": { - "message": "Edit billing address" + "message": "Rechnungsadresse bearbeiten" }, "noBillingAddress": { "message": "No address on file." }, "billingAddressUpdated": { - "message": "Your billing address has been updated." + "message": "Deine Rechnungsadresse wurde aktualisiert." }, "paymentDetails": { "message": "Payment details" @@ -10829,7 +10871,7 @@ "message": "Your payment method has been updated." }, "bankAccountVerified": { - "message": "Your bank account has been verified." + "message": "Dein Bankkonto wurde verifiziert." }, "availableCreditAppliedToInvoice": { "message": "Any available credit will be automatically applied towards invoices generated for this account." diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index b3e8d6d91d2..21f13a3440f 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Ασφαλής σημείωση" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Κλειδί SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Αντιγραφή ονόματος" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Εγώ" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web Vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 7993308c44a..2b3edba1ee9 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 1608845dca0..f2fb53f9b7d 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 86ed31ac4a4..ebc17d7e9b2 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sekura noto" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH-ŝlosilo" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopii la nomon" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Mi" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Rettrezorejo" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 1daee236d86..5c70de44650 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota segura" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Clave SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copiar nombre" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Yo" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Caja fuerte Web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Tu prueba gratuita termina en $COUNT$ días.", "placeholders": { diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index d95ff17edb8..6e865f9b5cb 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Turvaline märkus" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopeeri nimi" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Mina" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Veebihoidla" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Sinu tasuta prooviaeg lõppeb $COUNT$ päeva pärast.", "placeholders": { diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 546ffae4aea..fe066e152c0 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Ohar segurua" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ni" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webguneko kutxa gotorra" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index a4072ddca87..8e8fd9663bf 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "یادداشت امن" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "کلید SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "کپی نام" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "من" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "گاوصندوق وب" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "شما تلاش برای ورود به سیستم از دستگاه دیگری را رد کردید. اگر واقعاً این شما بودید، سعی کنید دوباره با دستگاه وارد شوید." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "درخواست ورود قبلاً منقضی شده است." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "بررسی درخواست ورود" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "دوره آزمایشی رایگان شما در $COUNT$ روز به پایان می‌رسد.", "placeholders": { diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index deb06c4c2b7..b2387149755 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Salattu muistio" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH-avain" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopioi nimi" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Minä" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Verkkoholvi" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "Komentorivi" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Kirjautumispyyntö on jo erääntynyt." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Tarkastele kirjautumispyyntöä" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Ilmainen kokeilujakso päättyy $COUNT$ päivän kuluttua.", "placeholders": { diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 9925074ad18..b56828be0e4 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure na tala" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ako" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 5ca186d843c..9a86b4aceb5 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Note sécurisée" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Clé SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copier le nom" }, + "cardNumber": { + "message": "numéro de carte" + }, + "copyFieldCipherName": { + "message": "Copier $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Moi" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Coffre web" }, + "webApp": { + "message": "Application web" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Vous avez refusé une tentative de connexion depuis un autre appareil. Si c'était vraiment vous, essayez de vous connecter à nouveau avec l'appareil." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Demande de connexion approuvée pour $EMAIL$ sur $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Vous avez refusé une tentative de connexion depuis un autre appareil. Si c'était vous, essayez de vous connecter à nouveau avec l'appareil." + }, "loginRequestHasAlreadyExpired": { "message": "La demande de connexion a déjà expiré." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Examiner la demande de connexion" }, + "loginRequest": { + "message": "Demande de connexion" + }, "freeTrialEndPromptCount": { "message": "Votre essai gratuit se termine dans $COUNT$ jours.", "placeholders": { diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index d22f7a2278c..a456d62e8c6 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota segura" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 476f2603b68..6d0d2e38166 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "הערה מאובטחת" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "מפתח SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "העתק שם" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "אני" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "כספת רשת" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "דחית ניסיון כניסה ממכשיר אחר. אם זה באמת היית אתה, נסה להיכנס עם המכשיר שוב." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "כבר פג תוקפה של בקשת הכניסה." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "סקור בקשת כניסה" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "הניסיון החינמי שלך מסתיים בעוד $COUNT$ ימים.", "placeholders": { diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index ebb408a90e1..301e8a7abb3 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "सुरक्षित नोट" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "मैं" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 2f706a33e7f..b3b2aec4eb6 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sigurna bilješka" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH ključ" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopiraj ime" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ja" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web trezor" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Odbijena je prijava na drugom uređaju. Ako si ovo stvarno ti, pokušaj se ponovno prijaviti uređajem." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Zahtjev za prijavu je već istekao." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Pregledaj zahtjev za prijavu" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Besplatno probno razdoblje završava za $COUNT$ dan/a.", "placeholders": { diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 6864afe594d..218cd5e0a2d 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Biztonságos jegyzet" }, + "typeNote": { + "message": "Jegyzet" + }, "typeSshKey": { "message": "SSH kulcs" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Név másolása" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Én" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webes széf" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Megtagadásra került egy bejelentkezési kísérletet egy másik eszközről. Ha valóban mi voltunk, próbáljunk meg újra bejelentkezni az eszközzel." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "A bejelentkezési kérés már lejárt." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Bejelentkezési kérés áttekintése" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Az ingyenes próbaidőszak $COUNT$ nap múlva ér véget.", "placeholders": { diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index b1d0554dc4d..d7d7e6699a3 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Catatan Aman" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Salin nama" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Saya" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Brankas web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 8549da6aadf..0fdcc4952c1 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota sicura" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Chiave SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copia nome" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Io" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Cassaforte web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Hai negato un tentativo di accesso da un altro dispositivo. Se eri davvero tu, prova di nuovo ad accedere con il dispositivo." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "La richiesta di accesso è già scaduta." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Rivedi richiesta di accesso" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Il tuo periodo di prova scade tra $COUNT$ giorni.", "placeholders": { diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 2e24fd7c27f..b23e971a8ae 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "セキュアメモ" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH 鍵" }, @@ -861,6 +864,23 @@ "copyName": { "message": "名前をコピー" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "自分" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "ウェブ保管庫" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "別のデバイスからのログイン試行を拒否しました。本当にあなたであった場合は、もう一度デバイスでログインしてみてください。" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "ログインリクエストの有効期限が切れています。" }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "ログインリクエストの内容を確認" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "無料体験はあと $COUNT$ 日で終了します。", "placeholders": { diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 9dba6b40e41..30cd7a8b70f 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "უსაფრთხო ჩანაწერი" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "მე" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 57b7a83469e..79d58617943 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index f47c3f6de5a..787da7b4dc9 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "ಸುರಕ್ಷಿತ ಟಿಪ್ಪಣಿ" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "ನನ್ನ" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "ವೆಬ್ ವಾಲ್ಟ್" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 29d34b2d587..af7442ce153 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "보안 메모" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "나" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "웹 보관함" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 2df1a726859..770979c8a59 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Droša piezīme" }, + "typeNote": { + "message": "Piezīme" + }, "typeSshKey": { "message": "SSH atslēga" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Ievietot nosaukumu starpliktuvē" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Es" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Tīmekļa glabātava" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Tu noraidīji pieteikšanās mēģinājumu no citas ierīces. Ja tas tiešām biji Tu, mēģini pieteikties no ierīces vēlreiz!" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Pieteikšanās pieprasījuma derīgums jau ir beidzies." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Izskatīt pieteikšanās pieprasījumu" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Bezmaksas izmēģinājums beigsies pēc $COUNT$ dienām.", "placeholders": { diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 0f37ab5065d..51dc8b6cdf9 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "സുരക്ഷിത കുറിപ്പ്" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web Vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 19cfb6bebc3..86c8a31943d 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 57b7a83469e..79d58617943 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index c076b67f21e..6018d3664f1 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Sikkert notat" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH-nøkkel" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopiér navn" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Meg" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Netthvelv" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "Ledetekst" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 80aed726a98..ba910035310 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 9ee85ea8021..45954a24a3f 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Veilige notitie" }, + "typeNote": { + "message": "Notitie" + }, "typeSshKey": { "message": "SSH-sleutel" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Naam kopiëren" }, + "cardNumber": { + "message": "kaartnummer" + }, + "copyFieldCipherName": { + "message": "$FIELD$, $CIPHERNAME$ kopiëren", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ik" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webkluis" }, + "webApp": { + "message": "Web-app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Je hebt een inlogpoging vanaf een ander apparaat geweigerd. Als je dit toch echt zelf was, probeer dan opnieuw in te loggen met het apparaat." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Inloggen voor $EMAIL$ goedgekeurd op $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Je hebt een inlogpoging vanaf een ander apparaat geweigerd. Als je dit toch echt zelf was, probeer dan opnieuw in te loggen met het apparaat." + }, "loginRequestHasAlreadyExpired": { "message": "Inlogverzoek is al verlopen." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Inlogverzoek afhandelen" }, + "loginRequest": { + "message": "Log-inverzoek" + }, "freeTrialEndPromptCount": { "message": "Je gratis proefperiode eindigt over $COUNT$ dagen.", "placeholders": { diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 9b74122c868..e04f1cb835f 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Trygg notat" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Eg" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 57b7a83469e..79d58617943 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 8665c3ddc32..749a073cdb1 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Bezpieczna notatka" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Klucz SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Skopiuj nazwę" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ja" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Sejf internetowy" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Odrzucono próby logowania z innego urządzenia. Jeśli to naprawdę Ty, spróbuj ponownie zalogować się za pomocą urządzenia." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Prośba logowania wygasła." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Przejrzyj żądanie logowania" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Twój okres próbny kończy się za $COUNT$ dni.", "placeholders": { diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 4654fe2c176..09d4b078ee0 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota Segura" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Chave SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copiar nome" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Eu" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Cofre Web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Você negou uma tentativa de acesso de outro dispositivo. Se isso realmente foi você, tente fazer login com o dispositivo novamente." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "O pedido de login já expirou." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Revisar solicitação de login" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Seu teste gratuito termina em $COUNT$ dias.", "placeholders": { diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index e22731fda35..c7d772d8003 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Nota segura" }, + "typeNote": { + "message": "Nota" + }, "typeSshKey": { "message": "Chave SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copiar nome" }, + "cardNumber": { + "message": "número do cartão" + }, + "copyFieldCipherName": { + "message": "Copiar $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Eu" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Cofre web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Recusou uma tentativa de início de sessão de outro dispositivo. Se foi realmente o caso, tente iniciar sessão com o dispositivo novamente." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Pedido de início de sessão aprovado para $EMAIL$ no $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Recusou uma tentativa de início de sessão de outro dispositivo. Se foi realmente o caso, tente iniciar sessão com o dispositivo novamente." + }, "loginRequestHasAlreadyExpired": { "message": "O pedido de início de sessão já expirou." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Rever pedido de início de sessão" }, + "loginRequest": { + "message": "Pedido de início de sessão" + }, "freeTrialEndPromptCount": { "message": "O seu período experimental gratuito termina dentro de $COUNT$ dias.", "placeholders": { @@ -8939,11 +8981,11 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Opções avançadas", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Aviso", "description": "Warning (should maintain locale-relevant capitalization)" }, "maintainYourSubscription": { diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 471b6af342b..3d3e48f0893 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Notă securizată" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Cheie SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copiați numele" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Eu" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Seif web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 944acc70862..4cf70f03865 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Защищенная заметка" }, + "typeNote": { + "message": "Заметка" + }, "typeSshKey": { "message": "Ключ SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Скопировать имя" }, + "cardNumber": { + "message": "номер карты" + }, + "copyFieldCipherName": { + "message": "Копировать $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Мое" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Веб-хранилище" }, + "webApp": { + "message": "Веб-приложение" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Вы отклонили попытку авторизации с другого устройства. Если это действительно были вы, попробуйте авторизоваться с этого устройства еще раз." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Запрос входа для $EMAIL$ на $DEVICE$ одобрен", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Вы отклонили попытку авторизации с другого устройства. Если это были вы, попробуйте авторизоваться с этого устройства еще раз." + }, "loginRequestHasAlreadyExpired": { "message": "Запрос на вход истек." }, @@ -3971,7 +4010,7 @@ "message": "Только что" }, "requestedXMinutesAgo": { - "message": "Запрошено $MINUTES$ мин назад", + "message": "Запрошено $MINUTES$ минут назад", "placeholders": { "minutes": { "content": "$1", @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Просмотр запроса на вход" }, + "loginRequest": { + "message": "Запрос на вход" + }, "freeTrialEndPromptCount": { "message": "Ваша бесплатная пробная версия заканчивается через $COUNT$ дней.", "placeholders": { diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 98bb0c5bfdc..03a569bc56f 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 3ffbd08d86e..ca1edfd11f7 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Zabezpečená poznámka" }, + "typeNote": { + "message": "Poznámka" + }, "typeSshKey": { "message": "Kľúč SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopírovať meno" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ja" }, @@ -1785,7 +1805,7 @@ "message": "Ak budete pokračovať, budete odhlásený a budete sa musieť opäť prihlásiť. Aktívne sedenia na iných zariadeniach môžu byť aktívne ešte hodinu." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Po zmene hesla sa musíte prihlásiť pomocou nového hesla. Aktívne relácie na iných zariadeniach budú do jednej hodiny odhlásené." }, "emailChanged": { "message": "E-mail bol zmenený" @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webový trezor" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli naozaj vy, skúste sa prihlásiť pomocou zariadenia znova." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Platnosť žiadosti o prihlásenie už vypršala." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Skontrolovať požiadavku o prihlásenie" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Vaše bezplatné skúšobné obdobie vyprší o $COUNT$ dní.", "placeholders": { @@ -6077,10 +6119,10 @@ "message": "Aktualizovať hlavné heslo" }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Zmeňte hlavné heslo, aby ste dokončili obnovenie účtu." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "Vaše hlavné heslo nespĺňa požiadavky tejto organizácie. Ak chcete pokračovať, zmeňte hlavné heslo." }, "updateMasterPasswordWarning": { "message": "Vaše hlavné heslo nedávno zmenil správca vo vašej organizácii. Ak chcete získať prístup k trezoru, musíte aktualizovať vaše hlavné heslo teraz. Pokračovaním sa odhlásite z aktuálnej relácie a budete sa musieť znova prihlásiť. Aktívne relácie na iných zariadeniach môžu zostať aktívne až jednu hodinu." @@ -8463,10 +8505,10 @@ "message": "Žiadosť o schválenie správcom" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Nepodarilo sa dokončiť prihlásenie" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Musíte sa prihlásiť na dôveryhodnom zariadení alebo požiadať správcu o priradenie hesla." }, "trustedDeviceEncryption": { "message": "Šifrovanie dôveryhodného zariadenia" @@ -8923,27 +8965,27 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Zisťovanie zhody URI je spôsob, akým Bitwarden identifikuje návrhy na automatické vypĺňanie.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Regulárny výraz\" je pokročilá možnosť so zvýšeným rizikom odhalenia prihlasovacích údajov.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Začína na\" je rozšírená možnosť so zvýšeným rizikom odhalenia prihlasovacích údajov.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Viac informácií o zisťovaní zhody", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Rozšírené možnosti", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Upozornenie", "description": "Warning (should maintain locale-relevant capitalization)" }, "maintainYourSubscription": { @@ -10737,49 +10779,49 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "setupExtensionPageTitle": { - "message": "Autofill your passwords securely with one click" + "message": "Jedným klikom automaticky a bezpečne vyplňte vaše heslá" }, "setupExtensionPageDescription": { - "message": "Get the Bitwarden browser extension and start autofilling today" + "message": "Získajte rozšírenie Bitwarden pre prehliadače a začnite automaticky vypĺňať heslá už dnes" }, "getTheExtension": { - "message": "Get the extension" + "message": "Získať rozšírenie" }, "addItLater": { - "message": "Add it later" + "message": "Pridať neskor" }, "cannotAutofillPasswordsWithoutExtensionTitle": { - "message": "You can't autofill passwords without the browser extension" + "message": "Bez rozšírenia Bitwarden pre prehliadače nie je možné automaticky vypĺňať heslá" }, "cannotAutofillPasswordsWithoutExtensionDesc": { - "message": "Are you sure you don't want to add the extension now?" + "message": "Naozaj teraz nechcete pridať rozšírenie?" }, "skipToWebApp": { - "message": "Skip to web app" + "message": "Preskočiť na webovú aplikáciu" }, "bitwardenExtensionInstalled": { - "message": "Bitwarden extension installed!" + "message": "Rozšírenie Bitwarden nainštalované!" }, "openExtensionToAutofill": { - "message": "Open the extension to log in and start autofilling." + "message": "Otvorte rozšírenie, prihláste sa a začnite automatické vypĺňanie." }, "openBitwardenExtension": { - "message": "Open Bitwarden extension" + "message": "Otvoriť rozšírenie Bitwarden" }, "gettingStartedWithBitwardenPart1": { - "message": "For tips on getting started with Bitwarden visit the", + "message": "Pre tipy ako začať s Bitwarden, navštívte", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart2": { - "message": "Learning Center", + "message": "Vzdelávacie centrum", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart3": { - "message": "Help Center", + "message": "Centrum pomoci", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "setupExtensionContentAlt": { - "message": "With the Bitwarden browser extension you can easily create new logins, access your saved logins directly from your browser toolbar, and sign in to accounts quickly using Bitwarden autofill." + "message": "S rozšírením Bitwarden pre prehliadače ľahko vytvorite nove prihlasovacie údaje, pristúpite k vaším uloženým údajom priamo z prehliadača a rýchlo sa prihlásite pomocou Bitwarden automatického vypĺňania." }, "restart": { "message": "Reštartovať" @@ -10808,46 +10850,46 @@ "description": "Error message shown when trying to add credit to a trialing organization without a billing address." }, "billingAddress": { - "message": "Billing address" + "message": "Fakturačná adresa" }, "addBillingAddress": { - "message": "Add billing address" + "message": "Pridať fakturačnú adresu" }, "editBillingAddress": { - "message": "Edit billing address" + "message": "Upraviť fakturačnú adresu" }, "noBillingAddress": { - "message": "No address on file." + "message": "Žiadna adresa v záznamoch." }, "billingAddressUpdated": { - "message": "Your billing address has been updated." + "message": "Vaša fakturačná adresa bola aktualizovaná." }, "paymentDetails": { - "message": "Payment details" + "message": "Platobné údaje" }, "paymentMethodUpdated": { - "message": "Your payment method has been updated." + "message": "Vaša platobná metóda bola aktualizovaná." }, "bankAccountVerified": { - "message": "Your bank account has been verified." + "message": "Váš bankový účet bol overený." }, "availableCreditAppliedToInvoice": { - "message": "Any available credit will be automatically applied towards invoices generated for this account." + "message": "Dostupný kredit sa automaticky použije na faktúry vytvorené pre tento účet." }, "mustBePositiveNumber": { - "message": "Must be a positive number" + "message": "Musí byť kladné číslo" }, "cardSecurityCode": { - "message": "Card security code" + "message": "Bezpečnostný kód karty" }, "cardSecurityCodeDescription": { - "message": "Card security code, also known as CVV or CVC, is typically a 3 digit number printed on the back of your credit card or 4 digit number printed on the front above your card number." + "message": "Bezpečnostný kód karty, známy aj ako CVV alebo CVC, je zvyčajne trojmiestne číslo vytlačené na zadnej strane kreditnej karty alebo štvormiestne číslo vytlačené na prednej strane nad číslom karty." }, "verifyBankAccountWarning": { - "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the Payment Details page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." + "message": "Platba prostredníctvom bankového účtu je dostupná len pre zákazníkov v Spojených Štátoch. Budete musieť overiť svoj bankový účet. V priebehu nasledujúcich 1-2 pracovných dní vykonáme mikro vklad. Na overenie bankového účtu zadajte kód popisu výpisu z tohto vkladu na fakturačnej stránke. Neoverenie bankového účtu bude mať za následok neuskutočnenie platby a pozastavenie vášho predplatného." }, "taxId": { - "message": "Tax ID: $TAX_ID$", + "message": "Daňové ID: $TAX_ID$", "placeholders": { "tax_id": { "content": "$1", diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 432c94ded26..d210b85efb4 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Zavarovan zapisek" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Jaz" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 05ec916c8bb..eab8e098d81 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Zaštićena beleška" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index fd566100c6d..2bdd42ff340 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Сигурносна белешка" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH кључ" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Копирати име" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ја" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Интернет Сеф" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Одбили сте покушај пријаве са другог уређаја. Ако сте то заиста били ви, покушајте поново да се пријавите помоћу уређаја." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Захтев за пријаву је већ истекао." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Прегледајте захтев за пријаву" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Ваша проба се завршава за $COUNT$ дана.", "placeholders": { diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 8a8b5d4fdc7..822dde707b1 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Säker anteckning" }, + "typeNote": { + "message": "Anteckning" + }, "typeSshKey": { "message": "SSH-nyckel" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Kopiera namn" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Jag" }, @@ -1061,7 +1081,7 @@ "message": "Logga in med huvudlösenord" }, "readingPasskeyLoading": { - "message": "Läser nyckel..." + "message": "Läser inloggningsnyckel..." }, "readingPasskeyLoadingInfo": { "message": "Håll det här fönstret öppet och följ anvisningarna från din webbläsare." @@ -1085,7 +1105,7 @@ "message": "Tvåstegsverifiering stöds inte för nycklar. Uppdatera appen för att logga in." }, "loginWithPasskeyInfo": { - "message": "Använd en genererad nyckel som automatiskt loggar in dig utan lösenord. Din identitet verifieras med biometri, såsom ansiktsigenkänning eller fingeravtryck, eller en annan FIDO2-säkerhetsmetod." + "message": "Använd en genererad inloggningsnyckel som automatiskt loggar in dig utan lösenord. Din identitet verifieras med biometri, såsom ansiktsigenkänning eller fingeravtryck, eller en annan FIDO2-säkerhetsmetod." }, "newPasskey": { "message": "Ny nyckel" @@ -1100,16 +1120,16 @@ "message": "Håll det här fönstret öppet och följ anvisningarna från din webbläsare." }, "errorCreatingPasskey": { - "message": "Fel vid skapande av nyckel" + "message": "Fel vid skapande av inloggningsnyckel" }, "errorCreatingPasskeyInfo": { - "message": "Det uppstod ett problem med att skapa nyckeln." + "message": "Det uppstod ett problem med att skapa din inloggningsnyckel." }, "passkeySuccessfullyCreated": { - "message": "Nyckeln har skapats!" + "message": "Inloggningsnyckeln har skapats!" }, "customPasskeyNameInfo": { - "message": "Namnge din nyckel för att hjälpa dig att identifiera den." + "message": "Namnge din inloggningsnyckel för att hjälpa dig att identifiera den." }, "useForVaultEncryption": { "message": "Använd för valvkryptering" @@ -1118,7 +1138,7 @@ "message": "Logga in och lås upp utan ditt huvudlösenord på enheter som stöds. Följ anvisningarna från din webbläsare för att slutföra konfigurationen." }, "useForVaultEncryptionErrorReadingPasskey": { - "message": "Fel när nyckeln skulle läsas. Försök igen eller avmarkera det här alternativet." + "message": "Fel när inloggningsnyckeln skulle läsas. Försök igen eller avmarkera det här alternativet." }, "encryptionNotSupported": { "message": "Kryptering stöds inte" @@ -1130,7 +1150,7 @@ "message": "Används för kryptering" }, "loginWithPasskeyEnabled": { - "message": "Inloggning med nyckel är aktiverad" + "message": "Inloggning med inloggningsnyckel är aktiverad" }, "passkeySaved": { "message": "$NAME$ sparad", @@ -1142,16 +1162,16 @@ } }, "passkeyRemoved": { - "message": "Nyckel borttagen" + "message": "Inloggningsnyckel borttagen" }, "removePasskey": { - "message": "Ta bort nyckel" + "message": "Ta bort inloggningsnyckel" }, "removePasskeyInfo": { "message": "Om alla nycklar tas bort kommer du inte kunna logga in på nya enheter utan ditt huvudlösenord." }, "passkeyLimitReachedInfo": { - "message": "Gränsen för antal nycklar har uppnåtts. Ta bort en nyckel innan du lägger till en ny." + "message": "Gränsen för antal inloggningsnycklar har uppnåtts. Ta bort en inloggningsnyckel innan du lägger till en ny." }, "tryAgain": { "message": "Försök igen" @@ -3482,6 +3502,9 @@ "webVault": { "message": "Webbvalv" }, + "webApp": { + "message": "Webbapp" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Du nekade ett inloggningsförsök från en annan enhet. Om detta verkligen var du, försök att logga in med enheten igen." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Inloggningsbegäran godkänd för $EMAIL$ på $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Du nekade ett inloggningsförsök från en annan enhet. Om det var du, försök att logga in med enheten igen." + }, "loginRequestHasAlreadyExpired": { "message": "Inloggningsbegäran har redan löpt ut." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Granska begäran om inloggning" }, + "loginRequest": { + "message": "Inloggningsbegäran" + }, "freeTrialEndPromptCount": { "message": "Din kostnadsfria testperiod avslutas om $COUNT$ dagar.", "placeholders": { @@ -5048,7 +5090,7 @@ "message": "SSO-identifierare" }, "ssoIdentifierHintPartOne": { - "message": "Ge detta ID till dina medlemmar så att de kan logga in med SSO. För att kringgå detta steg, konfigurera", + "message": "Ge detta ID till dina medlemmar så att de kan logga in med SSO. För att kringgå detta steg, konfigurera ", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Provide this ID to your members to login with SSO. To bypass this step, set up Domain verification'" }, "unlinkSso": { @@ -6582,7 +6624,7 @@ "message": "Migrerad till Key Connector" }, "paymentSponsored": { - "message": "Ange en betalningsmetod som ska kopplas till organisationen. Oroa dig inte, vi kommer inte att debitera dig något om du inte väljer ytterligare funktioner eller om ditt sponsorskap upphör." + "message": "Ange en betalningsmetod som ska kopplas till organisationen. Oroa dig inte, vi kommer inte att debitera dig något om du inte väljer ytterligare funktioner eller om ditt sponsorskap upphör. " }, "orgCreatedSponsorshipInvalid": { "message": "Sponsringserbjudandet har löpt ut. Du kan ta bort den organisation du skapade för att undvika en kostnad i slutet av din 7-dagars provperiod. Annars kan du stänga den här prompten för att behålla organisationen och ta på dig faktureringsansvaret." @@ -8855,10 +8897,10 @@ "message": "Nyckel" }, "passkeyNotCopied": { - "message": "Nyckeln kommer inte att kopieras" + "message": "Inloggningsnyckeln kommer inte att kopieras" }, "passkeyNotCopiedAlert": { - "message": "Nyckeln kommer inte att kopieras till det klonade föremålet. Vill du fortsätta klona det här objektet?" + "message": "Inloggningsnyckeln kommer inte att kopieras till det klonade föremålet. Vill du fortsätta klona det här objektet?" }, "modifiedCollectionManagement": { "message": "Modifierad inställning för samlingshantering $ID$.", @@ -8970,7 +9012,7 @@ "message": "Tack för att du anmälde dig till Bitwarden Secrets Manager!" }, "smFreeTrialConfirmationEmail": { - "message": "Vi har skickat ett bekräftelsemail till din e-postadress på" + "message": "Vi har skickat ett bekräftelsemail till din e-postadress på " }, "sorryToSeeYouGo": { "message": "Ledsen att se dig gå! Hjälp till att förbättra Bitwarden genom att dela med dig av varför du avbokar.", @@ -9254,7 +9296,7 @@ "message": "Ge grupper eller personer tillgång till detta maskinkonto." }, "machineAccountProjectsDescription": { - "message": "Tilldela projekt till detta maskinkonto." + "message": "Tilldela projekt till detta maskinkonto. " }, "createMachineAccount": { "message": "Skapa ett maskinkonto" @@ -9398,7 +9440,7 @@ "message": "SCIM" }, "scimIntegrationDescStart": { - "message": "Konfigurera", + "message": "Konfigurera ", "description": "This represents the beginning of a sentence, broken up to include links. The full sentence will be 'Configure SCIM (System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider" }, "scimIntegrationDescEnd": { @@ -9807,10 +9849,10 @@ "message": "Ladda ner CSV" }, "monthlySubscriptionUserSeatsMessage": { - "message": "Justeringar av din prenumeration kommer att resultera i proportionella debiteringar av dina faktureringssummor på din nästa faktureringsperiod." + "message": "Justeringar av din prenumeration kommer att resultera i proportionella debiteringar av dina faktureringssummor på din nästa faktureringsperiod. " }, "annualSubscriptionUserSeatsMessage": { - "message": "Justeringar av din prenumeration kommer att resultera i proportionella avgifter på en månatlig faktureringscykel." + "message": "Justeringar av din prenumeration kommer att resultera i proportionella avgifter på en månatlig faktureringscykel. " }, "billingHistoryDescription": { "message": "Ladda ner en CSV-fil för att få fram klientuppgifter för varje faktureringsdatum. Proraterade avgifter ingår inte i CSV-filen och kan skilja sig från den länkade fakturan. De mest exakta faktureringsuppgifterna hittar du på dina månadsfakturor.", @@ -9974,7 +10016,7 @@ "message": "Valfri lokal hosting" }, "upgradeFreeOrganization": { - "message": "Uppgradera din $NAME$-organisation", + "message": "Uppgradera din $NAME$-organisation ", "placeholders": { "name": { "content": "$1", @@ -10835,7 +10877,7 @@ "message": "Eventuell tillgänglig kredit kommer automatiskt att tillämpas på fakturor som genereras för detta konto." }, "mustBePositiveNumber": { - "message": "Måste vara ett positivt nummer." + "message": "Måste vara ett positivt tal" }, "cardSecurityCode": { "message": "Kortets säkerhetskod" diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 57b7a83469e..79d58617943 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Me" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 9562698d1b3..7adae0d45f7 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Secure note" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "ฉัน" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web vault" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Login request has already expired." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 4a336df5a9f..34b549ba813 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Güvenli not" }, + "typeNote": { + "message": "Not" + }, "typeSshKey": { "message": "SSH key" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Adı kopyala" }, + "cardNumber": { + "message": "kart numarası" + }, + "copyFieldCipherName": { + "message": "Kopyala: $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Ben" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Web kasası" }, + "webApp": { + "message": "Web uygulaması" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Başka bir cihazdan giriş isteğini reddettiniz. Yanlışlıkla yaptıysanız aynı cihazdan yeniden giriş yapmayı deneyin." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "$DEVICE$ cihazında $EMAIL$ girişi onaylandı", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "Başka bir cihazdan giriş isteğini reddettiniz. Yanlışlıkla yaptıysanız aynı cihazdan yeniden giriş yapmayı deneyin." + }, "loginRequestHasAlreadyExpired": { "message": "Giriş isteğinin süresi doldu." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Giriş isteği" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index cc59b1b39c9..3fa6e0cceb1 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Захищена нотатка" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Ключ SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Копіювати ім'я" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Я" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Вебсховище" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Ви відхилили спробу входу з іншого пристрою. Якщо це були дійсно ви, спробуйте увійти з пристроєм знову." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Термін дії запиту на вхід завершився." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Переглянути запит входу" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Ваш безплатний пробний період завершується через $COUNT$ днів.", "placeholders": { diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index b1bc5557228..a9832ed445c 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "Ghi chú" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "Khóa SSH" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Sao chép tên" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "Tôi" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "Kho web" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "Bạn đã từ chối một lần đăng nhập từ thiết bị khác. Nếu thực sự là bạn, hãy thử đăng nhập lại bằng thiết bị đó." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "Yêu cầu đăng nhập đã hết hạn." }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Xem xét yêu cầu đăng nhập" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "Thời gian dùng thử miễn phí của bạn sẽ kết thúc trong $COUNT$ ngày.", "placeholders": { diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 42157058174..5936ce9d218 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "安全笔记" }, + "typeNote": { + "message": "笔记" + }, "typeSshKey": { "message": "SSH 密钥" }, @@ -861,6 +864,23 @@ "copyName": { "message": "复制名称" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "我" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "网页密码库" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "您拒绝了一个来自其他设备的登录尝试。若确实是您本人,请尝试再次发起设备登录。" }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "登录请求已过期。" }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "审查登录请求" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "您的免费试用将于 $COUNT$ 天后结束。", "placeholders": { diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 3cdf7bd7944..4bba75e84ef 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -647,6 +647,9 @@ "typeSecureNote": { "message": "安全筆記" }, + "typeNote": { + "message": "Note" + }, "typeSshKey": { "message": "SSH 金鑰" }, @@ -861,6 +864,23 @@ "copyName": { "message": "Copy name" }, + "cardNumber": { + "message": "card number" + }, + "copyFieldCipherName": { + "message": "Copy $FIELD$, $CIPHERNAME$", + "description": "Title for a button that copies a field value to the clipboard.", + "placeholders": { + "field": { + "content": "$1", + "example": "Username" + }, + "ciphername": { + "content": "$2", + "example": "Login Item" + } + } + }, "me": { "message": "我" }, @@ -3482,6 +3502,9 @@ "webVault": { "message": "網頁版密碼庫" }, + "webApp": { + "message": "Web app" + }, "cli": { "message": "CLI 命令列介面" }, @@ -3964,6 +3987,22 @@ "youDeniedALogInAttemptFromAnotherDevice": { "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, + "loginRequestApprovedForEmailOnDevice": { + "message": "Login request approved for $EMAIL$ on $DEVICE$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "device": { + "content": "$2", + "example": "Web app - Chrome" + } + } + }, + "youDeniedLoginAttemptFromAnotherDevice": { + "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + }, "loginRequestHasAlreadyExpired": { "message": "登入要求已逾期。" }, @@ -4108,6 +4147,9 @@ "reviewLoginRequest": { "message": "Review login request" }, + "loginRequest": { + "message": "Login request" + }, "freeTrialEndPromptCount": { "message": "您的免費試用將於 $COUNT$ 天後結束。", "placeholders": { From 2cb2dc177efbb7be458f9a67b2d71a20b7483824 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:40:00 +0200 Subject: [PATCH 20/54] Autosync the updated translations (#15690) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/az/messages.json | 14 ++--- apps/web/src/locales/bg/messages.json | 12 ++--- apps/web/src/locales/cs/messages.json | 4 +- apps/web/src/locales/de/messages.json | 68 ++++++++++++------------ apps/web/src/locales/hu/messages.json | 12 ++--- apps/web/src/locales/lv/messages.json | 12 ++--- apps/web/src/locales/pl/messages.json | 14 ++--- apps/web/src/locales/pt_PT/messages.json | 8 +-- apps/web/src/locales/sk/messages.json | 12 ++--- apps/web/src/locales/sv/messages.json | 4 +- apps/web/src/locales/zh_CN/messages.json | 4 +- 11 files changed, 82 insertions(+), 82 deletions(-) diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 4bcb8431331..2c51163b62c 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -648,7 +648,7 @@ "message": "Güvənli qeyd" }, "typeNote": { - "message": "Note" + "message": "Not" }, "typeSshKey": { "message": "SSH açarı" @@ -865,10 +865,10 @@ "message": "Adı kopyala" }, "cardNumber": { - "message": "card number" + "message": "kart nömrəsi" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopyala: $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Veb seyf" }, "webApp": { - "message": "Web app" + "message": "Veb tətbiq" }, "cli": { "message": "CLI" @@ -3988,7 +3988,7 @@ "message": "Başqa bir cihazdan giriş cəhdinə rədd cavabı verdiniz. Bu həqiqətən siz idinizsə, cihazla yenidən giriş etməyə çalışın." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$DEVICE$ cihazında $EMAIL$ üçün giriş tələbi təsdiqləndi", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Başqa bir cihazdan giriş cəhdinə rədd cavabı verdiniz. Bu siz idinizsə, cihazla yenidən giriş etməyə çalışın." }, "loginRequestHasAlreadyExpired": { "message": "Giriş tələbinin müddəti artıq bitib." @@ -4148,7 +4148,7 @@ "message": "Giriş tələbini incələ" }, "loginRequest": { - "message": "Login request" + "message": "Giriş tələbi" }, "freeTrialEndPromptCount": { "message": "Ödənişsiz sınaq müddətiniz $COUNT$ günə bitir.", diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 4bcfd56e95c..315ce4ee30b 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -865,10 +865,10 @@ "message": "Копиране на името" }, "cardNumber": { - "message": "card number" + "message": "номер на карта" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Копиране на $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Трезор по уеб" }, "webApp": { - "message": "Web app" + "message": "Приложение по уеб" }, "cli": { "message": "ИКР" @@ -3988,7 +3988,7 @@ "message": "Вие отказахте опит за вписване от друго устройство. Ако това наистина сте били Вие, опитайте да се впишете от устройството отново." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Заявката за вписване за $EMAIL$ на $DEVICE$ е одобрена", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Вие отказахте опит за вписване от друго устройство. Ако това сте били Вие, опитайте да се впишете от устройството отново." }, "loginRequestHasAlreadyExpired": { "message": "Заявката за вписване вече е изтекла." @@ -4148,7 +4148,7 @@ "message": "Преглед на заявката за вписване" }, "loginRequest": { - "message": "Login request" + "message": "Заявка за вписване" }, "freeTrialEndPromptCount": { "message": "Вашият безплатен пробен период приключва след $COUNT$ дни.", diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 6ae11489480..8569b953fb7 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -865,10 +865,10 @@ "message": "Kopírovat název" }, "cardNumber": { - "message": "card number" + "message": "číslo karty" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopírovat $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 3b0d8cccf97..6c94a17f36c 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -868,7 +868,7 @@ "message": "Kartennummer" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ kopieren", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1805,7 +1805,7 @@ "message": "Wenn du fortfährst, wirst du aus deiner aktuellen Sitzung ausgeloggt. Aktive Sitzungen auf anderen Geräten können bis zu einer Stunde weiterhin aktiv bleiben." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Nachdem du dein Passwort geändert hast, musst du dich mit deinem neuen Passwort anmelden. Aktive Sitzungen auf anderen Geräten werden innerhalb einer Stunde abgemeldet." }, "emailChanged": { "message": "E-Mail-Adresse gespeichert" @@ -3988,7 +3988,7 @@ "message": "Du hast einen Anmeldeversuch von einem anderen Gerät abgelehnt. Wenn du das wirklich warst, versuche dich erneut mit dem Gerät anzumelden." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Anmeldeanfrage für $EMAIL$ auf $DEVICE$ genehmigt", "placeholders": { "email": { "content": "$1", @@ -6110,7 +6110,7 @@ "message": "Hinzufügen" }, "masterPasswordSuccessfullySet": { - "message": "Master-Passwort erfolgreich eingerichtet" + "message": "Master-Passwort erfolgreich festgelegt" }, "updatedMasterPassword": { "message": "Master-Passwort gespeichert" @@ -6122,7 +6122,7 @@ "message": "Ändere dein Master-Passwort, um die Kontowiederherstellung abzuschließen." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "Dein Master-Passwort entspricht nicht den Anforderungen dieser Organisation. Ändere dein Master-Passwort, um fortzufahren." }, "updateMasterPasswordWarning": { "message": "Dein Master-Passwort wurde kürzlich von einem Administrator deiner Organisation geändert. Um auf den Tresor zuzugreifen, musst du dein Master-Passwort jetzt aktualisieren. Wenn Du fortfährst, wirst du aus der aktuellen Sitzung abgemeldet und eine erneute Anmeldung ist erforderlich. Aktive Sitzungen auf anderen Geräten können bis zu einer Stunde weiterhin aktiv bleiben." @@ -8288,7 +8288,7 @@ "message": "Beim Lesen der Importdatei ist ein Fehler aufgetreten" }, "accessedSecretWithId": { - "message": "Accessed a secret with identifier: $SECRET_ID$", + "message": "Zugriff auf Geheimnis mit der Kennung: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -8306,7 +8306,7 @@ } }, "editedSecretWithId": { - "message": "Edited a secret with identifier: $SECRET_ID$", + "message": "Ein Geheimnis bearbeitet mit der Kennung: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -8315,7 +8315,7 @@ } }, "deletedSecretWithId": { - "message": "Deleted a secret with identifier: $SECRET_ID$", + "message": "Ein Geheimnis gelöscht mit der Kennung: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -8324,7 +8324,7 @@ } }, "createdSecretWithId": { - "message": "Created a new secret with identifier: $SECRET_ID$", + "message": "Ein neues Geheimnis erstellt mit Kennung: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -8508,7 +8508,7 @@ "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Du musst dich auf einem vertrauenswürdigen Gerät anmelden oder deinem Administrator bitten, dir ein Passwort zuzuweisen." }, "trustedDeviceEncryption": { "message": "Vertrauenswürdige Geräteverschlüsselung" @@ -8965,15 +8965,15 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Die URI-Übereinstimmungserkennung ist die Methode, mit der Bitwarden Auto-Ausfüllen-Vorschläge erkennt.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Regulärer Ausdruck\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Beginnt mit\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { @@ -8981,7 +8981,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Erweiterte Optionen", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { @@ -10779,37 +10779,37 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "setupExtensionPageTitle": { - "message": "Autofill your passwords securely with one click" + "message": "Fülle deine Passwörter sicher mit einem Klick automatisch aus" }, "setupExtensionPageDescription": { - "message": "Get the Bitwarden browser extension and start autofilling today" + "message": "Lade dir die Bitwarden Browser-Erweiterung herunter und nutze Auto-Ausfüllen noch heute" }, "getTheExtension": { - "message": "Get the extension" + "message": "Erweiterung herunterladen" }, "addItLater": { - "message": "Add it later" + "message": "Später hinzufügen" }, "cannotAutofillPasswordsWithoutExtensionTitle": { - "message": "You can't autofill passwords without the browser extension" + "message": "Du kannst Passwörter nicht ohne die Browser-Erweiterung automatisch ausfüllen" }, "cannotAutofillPasswordsWithoutExtensionDesc": { - "message": "Are you sure you don't want to add the extension now?" + "message": "Bist du sicher, dass du die Erweiterung jetzt nicht hinzufügen möchtest?" }, "skipToWebApp": { - "message": "Skip to web app" + "message": "Zur Web-App springen" }, "bitwardenExtensionInstalled": { "message": "Bitwarden-Erweiterung installiert!" }, "openExtensionToAutofill": { - "message": "Open the extension to log in and start autofilling." + "message": "Öffne die Erweiterung, um dich anzumelden und Auto-Ausfüllen zu nutzen." }, "openBitwardenExtension": { "message": "Bitwarden-Erweiterung öffnen" }, "gettingStartedWithBitwardenPart1": { - "message": "For tips on getting started with Bitwarden visit the", + "message": "Tipps für die ersten Schritte mit Bitwarden findest du unter", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart2": { @@ -10817,11 +10817,11 @@ "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "gettingStartedWithBitwardenPart3": { - "message": "Help Center", + "message": "Hilfezentrum", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "setupExtensionContentAlt": { - "message": "With the Bitwarden browser extension you can easily create new logins, access your saved logins directly from your browser toolbar, and sign in to accounts quickly using Bitwarden autofill." + "message": "Mit der Bitwarden Browser-Erweiterung kannst du ganz einfach neue Zugangsdaten erstellen, auf deine gespeicherten Zugangsdaten direkt von deiner Browser-Symbolleiste aus zugreifen und dich schnell mit Bitwarden Auto-Ausfüllen bei Konten anmelden." }, "restart": { "message": "Neustarten" @@ -10859,37 +10859,37 @@ "message": "Rechnungsadresse bearbeiten" }, "noBillingAddress": { - "message": "No address on file." + "message": "Keine Adresse angegeben." }, "billingAddressUpdated": { "message": "Deine Rechnungsadresse wurde aktualisiert." }, "paymentDetails": { - "message": "Payment details" + "message": "Zahlungsdaten" }, "paymentMethodUpdated": { - "message": "Your payment method has been updated." + "message": "Deine Zahlungsart wurde aktualisiert." }, "bankAccountVerified": { "message": "Dein Bankkonto wurde verifiziert." }, "availableCreditAppliedToInvoice": { - "message": "Any available credit will be automatically applied towards invoices generated for this account." + "message": "Jedes verfügbare Guthaben wird automatisch auf die für dieses Konto erstellten Rechnungen angerechnet." }, "mustBePositiveNumber": { - "message": "Must be a positive number" + "message": "Muss eine positive Zahl sein" }, "cardSecurityCode": { - "message": "Card security code" + "message": "Karten-Sicherheitscode" }, "cardSecurityCodeDescription": { - "message": "Card security code, also known as CVV or CVC, is typically a 3 digit number printed on the back of your credit card or 4 digit number printed on the front above your card number." + "message": "Der Karten-Sicherheitscode, auch CVV oder CVC genannt, ist typischerweise eine dreistellige Zahl, die auf der Rückseite oder als vierstellige Zahl auf der Vorderseite über deiner Kartennummer gedruckt ist." }, "verifyBankAccountWarning": { - "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the Payment Details page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." + "message": "Die Zahlung mit einem Bankkonto ist nur für Kunden in den Vereinigten Staaten möglich. Du musst dein Bankkonto verifizieren. Wir werden innerhalb der nächsten 1-2 Werktage eine Mikro-Einzahlung vornehmen. Gib den Code aus der Auszugsbeschreibung dieser Einzahlung ein, um das Bankkonto zu verifizieren. Schlägt die Verifizierung des Bankkontos fehl, wird dies als versäumte Zahlung gewertet und dein Abonnement gesperrt." }, "taxId": { - "message": "Tax ID: $TAX_ID$", + "message": "Steuernummer: $TAX_ID$", "placeholders": { "tax_id": { "content": "$1", diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 218cd5e0a2d..a38ed7f0c03 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -865,10 +865,10 @@ "message": "Név másolása" }, "cardNumber": { - "message": "card number" + "message": "kártya szám" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ másolása", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Webes széf" }, "webApp": { - "message": "Web app" + "message": "Webalkalmazás" }, "cli": { "message": "CLI" @@ -3988,7 +3988,7 @@ "message": "Megtagadásra került egy bejelentkezési kísérletet egy másik eszközről. Ha valóban mi voltunk, próbáljunk meg újra bejelentkezni az eszközzel." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "A bejelentkezési kérelem jóváhagyásra került: $EMAIL$ - $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Megtagadásra került egy bejelentkezési kísérletet egy másik eszközről. Ha valóban mi voltunk, próbáljunk meg újra bejelentkezni az eszközzel." }, "loginRequestHasAlreadyExpired": { "message": "A bejelentkezési kérés már lejárt." @@ -4148,7 +4148,7 @@ "message": "Bejelentkezési kérés áttekintése" }, "loginRequest": { - "message": "Login request" + "message": "Bejelentkezés kérés" }, "freeTrialEndPromptCount": { "message": "Az ingyenes próbaidőszak $COUNT$ nap múlva ér véget.", diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 770979c8a59..ff80c724835 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -865,10 +865,10 @@ "message": "Ievietot nosaukumu starpliktuvē" }, "cardNumber": { - "message": "card number" + "message": "kartes numurs" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Ievietot starpliktuvē $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Tīmekļa glabātava" }, "webApp": { - "message": "Web app" + "message": "Tīmekļa lietotne" }, "cli": { "message": "CLI" @@ -3988,7 +3988,7 @@ "message": "Tu noraidīji pieteikšanās mēģinājumu no citas ierīces. Ja tas tiešām biji Tu, mēģini pieteikties no ierīces vēlreiz!" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$EMAIL$ pieteikšanās pieprasījums apstiprināts $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Tu noraidīji pieteikšanās mēģinājumu no citas ierīces. Ja tas biji Tu, mēģini pieteikties no ierīces vēlreiz!" }, "loginRequestHasAlreadyExpired": { "message": "Pieteikšanās pieprasījuma derīgums jau ir beidzies." @@ -4148,7 +4148,7 @@ "message": "Izskatīt pieteikšanās pieprasījumu" }, "loginRequest": { - "message": "Login request" + "message": "Pieteikšanās pieprasījums" }, "freeTrialEndPromptCount": { "message": "Bezmaksas izmēģinājums beigsies pēc $COUNT$ dienām.", diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 749a073cdb1..e2d73ad47b3 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -648,7 +648,7 @@ "message": "Bezpieczna notatka" }, "typeNote": { - "message": "Note" + "message": "Notatka" }, "typeSshKey": { "message": "Klucz SSH" @@ -865,10 +865,10 @@ "message": "Skopiuj nazwę" }, "cardNumber": { - "message": "card number" + "message": "numer karty" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopiuj $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Sejf internetowy" }, "webApp": { - "message": "Web app" + "message": "Aplikacja internetowa" }, "cli": { "message": "CLI" @@ -3988,7 +3988,7 @@ "message": "Odrzucono próby logowania z innego urządzenia. Jeśli to naprawdę Ty, spróbuj ponownie zalogować się za pomocą urządzenia." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Logowanie potwierdzone dla $EMAIL$ na $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Odrzucono próby logowania z innego urządzenia. Jeśli to naprawdę Ty, spróbuj ponownie zalogować się za pomocą urządzenia." }, "loginRequestHasAlreadyExpired": { "message": "Prośba logowania wygasła." @@ -4148,7 +4148,7 @@ "message": "Przejrzyj żądanie logowania" }, "loginRequest": { - "message": "Login request" + "message": "Żądanie logowania" }, "freeTrialEndPromptCount": { "message": "Twój okres próbny kończy się za $COUNT$ dni.", diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index c7d772d8003..cda5cec824d 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -6702,7 +6702,7 @@ "message": "URL do servidor da API" }, "webVaultUrl": { - "message": "URL do servidor do cofre Web" + "message": "URL do servidor do cofre web" }, "identityUrl": { "message": "URL do servidor de identidade" @@ -8919,7 +8919,7 @@ "message": "Instalar a extensão do navegador" }, "installBrowserExtensionDetails": { - "message": "Utilize a extensão para guardar rapidamente as credenciais e preencher automaticamente formulários sem abrir a aplicação Web." + "message": "Utilize a extensão para guardar rapidamente as credenciais e preencher automaticamente formulários sem abrir a aplicação web." }, "projectAccessUpdated": { "message": "Acesso ao projeto atualizado" @@ -9050,7 +9050,7 @@ "message": "Gratuito durante 1 ano" }, "newWebApp": { - "message": "Bem-vindo à nova e melhorada aplicação Web. Saiba mais sobre o que mudou." + "message": "Bem-vindo à nova e melhorada aplicação web. Saiba mais sobre o que mudou." }, "releaseBlog": { "message": "Ler o blogue de lançamento" @@ -10797,7 +10797,7 @@ "message": "Tem a certeza de que não pretende adicionar a extensão agora?" }, "skipToWebApp": { - "message": "Saltar para a Web app" + "message": "Saltar para a aplicação web" }, "bitwardenExtensionInstalled": { "message": "Extensão Bitwarden instalada!" diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index ca1edfd11f7..5543fc20820 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -865,10 +865,10 @@ "message": "Kopírovať meno" }, "cardNumber": { - "message": "card number" + "message": "číslo karty" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopírovať $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -3503,7 +3503,7 @@ "message": "Webový trezor" }, "webApp": { - "message": "Web app" + "message": "Webová aplikácia" }, "cli": { "message": "CLI" @@ -3988,7 +3988,7 @@ "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli naozaj vy, skúste sa prihlásiť pomocou zariadenia znova." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Potvrdené prihlásenie pre $EMAIL$ na $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -4001,7 +4001,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli vy, skúste sa prihlásiť pomocou zariadenia znova." }, "loginRequestHasAlreadyExpired": { "message": "Platnosť žiadosti o prihlásenie už vypršala." @@ -4148,7 +4148,7 @@ "message": "Skontrolovať požiadavku o prihlásenie" }, "loginRequest": { - "message": "Login request" + "message": "Žiadosť o prihlásenie" }, "freeTrialEndPromptCount": { "message": "Vaše bezplatné skúšobné obdobie vyprší o $COUNT$ dní.", diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 822dde707b1..6fea9ba15d3 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -865,10 +865,10 @@ "message": "Kopiera namn" }, "cardNumber": { - "message": "card number" + "message": "kortnummer" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopiera $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 5936ce9d218..5bc6c472cd1 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -865,10 +865,10 @@ "message": "复制名称" }, "cardNumber": { - "message": "card number" + "message": "卡号" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "复制 $FIELD$、$CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { From 36e59c2e3b45725374b4e5071eed3f28dcfcaa46 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:43:48 +0200 Subject: [PATCH 21/54] Autosync the updated translations (#15692) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/az/messages.json | 8 +- apps/desktop/src/locales/bg/messages.json | 4 +- apps/desktop/src/locales/ca/messages.json | 132 +++++++++---------- apps/desktop/src/locales/cs/messages.json | 4 +- apps/desktop/src/locales/de/messages.json | 20 +-- apps/desktop/src/locales/hu/messages.json | 4 +- apps/desktop/src/locales/ja/messages.json | 18 +-- apps/desktop/src/locales/lv/messages.json | 4 +- apps/desktop/src/locales/pt_PT/messages.json | 4 +- apps/desktop/src/locales/sv/messages.json | 6 +- apps/desktop/src/locales/vi/messages.json | 4 +- apps/desktop/src/locales/zh_CN/messages.json | 4 +- apps/desktop/src/locales/zh_TW/messages.json | 78 +++++------ 13 files changed, 145 insertions(+), 145 deletions(-) diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index e5fee904d92..b04f3204a15 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -573,7 +573,7 @@ "message": "Doğrulama kodunu kopyala (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopyala: $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "kart nömrəsi" }, "premiumMembership": { "message": "Premium üzvlük" @@ -4016,9 +4016,9 @@ } }, "enableAutotype": { - "message": "Enable autotype shortcut" + "message": "Avto-yazma qısayolunu fəallaşdır" }, "enableAutotypeDescription": { - "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." + "message": "Bitwarden, giriş yerlərini doğrulamır, qısayolu istifadə etməzdən əvvəl doğru pəncərədə və xanada olduğunuza əmin olun." } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index cf74c69be46..92ee97ba16a 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -573,7 +573,7 @@ "message": "Код за потвърждаване (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Копиране на $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "номер на карта" }, "premiumMembership": { "message": "Платен абонамент" diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index ed49a360aa5..6534c0eef02 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -2214,7 +2214,7 @@ "message": "Identification" }, "contactInfo": { - "message": "Contact information" + "message": "Informació de contacte" }, "allSends": { "message": "Tots els Send", @@ -2380,10 +2380,10 @@ "message": "Autenticar WebAuthn" }, "readSecurityKey": { - "message": "Read security key" + "message": "Llegeix clau de seguretat" }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "S'està esperant la interacció amb la clau de seguretat..." }, "hideEmail": { "message": "Amagueu la meua adreça de correu electrònic als destinataris." @@ -2425,16 +2425,16 @@ "message": "La vostra contrasenya mestra no compleix una o més de les polítiques de l'organització. Per accedir a la caixa forta, heu d'actualitzar-la ara. Si continueu, es tancarà la sessió actual i us demanarà que torneu a iniciar-la. Les sessions en altres dispositius poden continuar romanent actives fins a una hora." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "En canviar la teva contrasenya, cal iniciar la sessió amb la nova contrasenya. Les sessions actives en altres dispositius es tancaran en una hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Canvia la contrasenya mestra per completar el recobrament del compte." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "La contrasenya mestra no s'ajusta als requisits de l'organització. Canvia't la contrasenya mestra per continuar." }, "tdeDisabledMasterPasswordRequired": { - "message": "Your organization has disabled trusted device encryption. Please set a master password to access your vault." + "message": "La teva organització ha desactivat l'encriptació de dispositius fiables. Fixa una contrasenya mestra per accedir a la teva caixa forta." }, "tryAgain": { "message": "Torneu-ho a provar" @@ -2479,7 +2479,7 @@ "message": "Minuts" }, "vaultTimeoutPolicyInEffect1": { - "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "message": "$HOURS$ hora(es) i $MINUTES$ minut(s) màxim.", "placeholders": { "hours": { "content": "$1", @@ -2545,13 +2545,13 @@ "message": "S'ha suprimit la contrasenya mestra." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "Ja no cal contrasenya mestra per als membres de la següent organització. Confirma'n el domini a sota amb l'administrador de la teva organització." }, "organizationName": { "message": "Nom de l'organització" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Domini del Connector de claus" }, "leaveOrganization": { "message": "Abandona l'organització" @@ -2614,7 +2614,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "message": "Només els objectes individuals de la caixa forta, inclosos adjunts associats amb $EMAIL$, seran exportats. Els objectes de la caixa forta de l'organització no hi seran inclosos", "placeholders": { "email": { "content": "$1", @@ -2740,7 +2740,7 @@ "message": "Utilitzeu aquesta contrasenya" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "Empra aquesta frase de pas" }, "useThisUsername": { "message": "Utilitzeu aquest nom d'usuari" @@ -2777,7 +2777,7 @@ "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Choose a domain that is supported by the selected service", + "message": "Tria un domini admès pel servei seleccionat", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { @@ -2809,7 +2809,7 @@ } }, "forwaderInvalidToken": { - "message": "Invalid $SERVICENAME$ API token", + "message": "API token de $SERVICENAME$ invàlid", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -2819,7 +2819,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$", + "message": "API token de $SERVICENAME$ invàlid: $ERRORMESSAGE$", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2833,7 +2833,7 @@ } }, "forwaderInvalidOperation": { - "message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.", + "message": "$SERVICENAME$ t'ha rebutjat la petició. Contacta amb el teu proveïdor de serveis per assistència.", "description": "Displayed when the user is forbidden from using the API by the forwarding service.", "placeholders": { "servicename": { @@ -2843,7 +2843,7 @@ } }, "forwaderInvalidOperationWithMessage": { - "message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$", + "message": "$SERVICENAME$ us ha rebutjat la petició: $ERRORMESSAGE$", "description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2857,7 +2857,7 @@ } }, "forwarderNoAccountId": { - "message": "Unable to obtain $SERVICENAME$ masked email account ID.", + "message": "No es pot obtenir l'ID de compte de correu electrònic emmascarat de $SERVICENAME$.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -2867,7 +2867,7 @@ } }, "forwarderNoDomain": { - "message": "Invalid $SERVICENAME$ domain.", + "message": "Domini de $SERVICENAME$ invàlid.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2877,7 +2877,7 @@ } }, "forwarderNoUrl": { - "message": "Invalid $SERVICENAME$ url.", + "message": "Url de $SERVICENAME$ invàlid.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -2887,7 +2887,7 @@ } }, "forwarderUnknownError": { - "message": "Unknown $SERVICENAME$ error occurred.", + "message": "Hi ha hagut un error desconegut amb $SERVICENAME$.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -2897,7 +2897,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Unknown forwarder: '$SERVICENAME$'.", + "message": "Remitent desconegut: «$SERVICENAME$».", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -2965,13 +2965,13 @@ "message": "S'ha enviat una notificació al vostre dispositiu" }, "notificationSentDevicePart1": { - "message": "Unlock Bitwarden on your device or on the " + "message": "Desbloqueja Bitwarden en el teu dispositiu o en el " }, "notificationSentDeviceAnchor": { "message": "aplicació web" }, "notificationSentDevicePart2": { - "message": "Make sure the Fingerprint phrase matches the one below before approving." + "message": "Assegura't que la frase d'empremta digital encaixa amb la d'aquí sota abans d'aprovar-la." }, "needAnotherOptionV1": { "message": "Necessiteu una altra opció?" @@ -3002,7 +3002,7 @@ "description": "'Character count' describes a feature that displays a number next to each character of the password." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Intentes accedir al teu compte?" }, "accessAttemptBy": { "message": "Intent d'inici de sessió per $EMAIL$", @@ -3063,7 +3063,7 @@ "message": "Aquesta sol·licitud ja no és vàlida." }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "Confirma l'intent d'accés de $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3075,7 +3075,7 @@ "message": "S'ha sol·licitat inici de sessió" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "Accés al compte demanat" }, "creatingAccountOn": { "message": "Creant compte en" @@ -3123,10 +3123,10 @@ "message": "Accedint a" }, "accessTokenUnableToBeDecrypted": { - "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." + "message": "Se t'ha tancat la sessió perquè el teu token d'accés no es podia desxifrar. Torna a iniciar la sessió per resoldre aquest problema." }, "refreshTokenSecureStorageRetrievalFailure": { - "message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue." + "message": "Se t'ha tancat la sessió perquè el teu token d'actualització no es podia recuperar. Torna a iniciar la sessió per resoldre aquest problema." }, "masterPasswordHint": { "message": "La contrasenya mestra no es pot recuperar si la oblideu!" @@ -3147,16 +3147,16 @@ "message": "Actualització de configuració recomanada" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Remember this device to make future logins seamless" + "message": "Recorda aquest dispositiu per futurs inicis de sessió sense interrupcions" }, "deviceApprovalRequired": { "message": "Cal l'aprovació del dispositiu. Seleccioneu una opció d'aprovació a continuació:" }, "deviceApprovalRequiredV2": { - "message": "Device approval required" + "message": "Cal aprovació del dispositiu" }, "selectAnApprovalOptionBelow": { - "message": "Select an approval option below" + "message": "Tria una opció d'aprovació a sota" }, "rememberThisDevice": { "message": "Recorda aquest dispositiu" @@ -3171,10 +3171,10 @@ "message": "Sol·liciteu l'aprovació de l'administrador" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "No s'ha pogut finalitzar l'inici de sessió" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Cal iniciar sessió en un dispositiu de confiança o demanar al teu administrador que t'assigni una contrasenya." }, "region": { "message": "Regió" @@ -3211,34 +3211,34 @@ "message": "Falta el correu electrònic de l'usuari" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "Active user email not found. Logging you out." + "message": "No s'ha trobat el correu electrònic de l'usuari actiu. Se't tancarà la sessió." }, "deviceTrusted": { "message": "Dispositiu de confiança" }, "trustOrganization": { - "message": "Trust organization" + "message": "Confia en l'organització" }, "trust": { "message": "Confiar" }, "doNotTrust": { - "message": "Do not trust" + "message": "No hi confiïs" }, "organizationNotTrusted": { - "message": "Organization is not trusted" + "message": "L'organització no és de confiança" }, "emergencyAccessTrustWarning": { - "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account" + "message": "Per la seguretat del teu compte, confirma només si tens garantit l'accés a aquest usuari i la seva empremta coincideix amb el que es mostra al seu compte" }, "orgTrustWarning": { - "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint." + "message": "Per la seguretat del teu compte, confirma només si ets un membre d'aquesta organització, tens un compte de recuperació activat i l'empremta de sota coincideix amb la de l'organització." }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "Aquesta organització té una política d'Empresa que t'inscriurà com a compte de recuperació. La inscripció permetrà als administradors de l'organització canviar la teva contrasenya. Continua només si coneixes l'organització i la frase d'empremta digital mostrada a sota coincideix amb l'empremta de l'organització." }, "trustUser": { - "message": "Trust user" + "message": "Confia en l'usuari" }, "inputRequired": { "message": "L'entrada és obligatòria." @@ -3405,13 +3405,13 @@ "message": "Es requereix l'inici de sessió en dos passos de DUO al vostre compte." }, "duoTwoFactorRequiredPageSubtitle": { - "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + "message": "Cal l'inici de sessió en dos passos de Duo al vostre compte. Seguiu els passos de sota per finalitzar l'inici de sessió." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Follow the steps below to finish logging in." + "message": "Seguiu els passos de sota per finalitzar l'inici de sessió." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "Seguiu els passos de sota per finalitzar l'inici de sessió amb la clau de seguretat." }, "launchDuo": { "message": "Inicia Duo al navegador" @@ -3568,27 +3568,27 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Bitwarden empra la detecció de coincidències URI pels suggeriments d'autoemplenament.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "«Expressió regular» és una opció avançada amb més risc d'exposar credencials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "«Comença amb» és una opció avançada amb més risc d'exposar credencials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Més sobre la detecció de coincidències", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Opcions avançades", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { - "message": "Warning", + "message": "Advertència", "description": "Warning (should maintain locale-relevant capitalization)" }, "success": { @@ -3659,33 +3659,33 @@ "message": "Sends de text" }, "ssoError": { - "message": "No free ports could be found for the sso login." + "message": "No s'ha trobat cap port lliure per a l'inici de sessió sso." }, "securePasswordGenerated": { - "message": "Secure password generated! Don't forget to also update your password on the website." + "message": "Contrasenya segura generada! No us oblideu d'actualitzar-vos la contrasenya al web." }, "useGeneratorHelpTextPartOne": { - "message": "Use the generator", + "message": "Feu servir el generador", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "useGeneratorHelpTextPartTwo": { - "message": "to create a strong unique password", + "message": "per crear una contrasenya única forta", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "biometricsStatusHelptextUnlockNeeded": { - "message": "Biometric unlock is unavailable because PIN or password unlock is required first." + "message": "El desbloqueig per dades biomètriques no està disponible perquè cal primer desbloquejar el PIN o la contrasenya." }, "biometricsStatusHelptextHardwareUnavailable": { - "message": "Biometric unlock is currently unavailable." + "message": "El desbloqueig per dades biomètriques no està disponible ara." }, "biometricsStatusHelptextAutoSetupNeeded": { - "message": "Biometric unlock is unavailable due to misconfigured system files." + "message": "El desbloqueig per dades biomètriques no està disponible per uns fitxers del sistema mal configurats." }, "biometricsStatusHelptextManualSetupNeeded": { - "message": "Biometric unlock is unavailable due to misconfigured system files." + "message": "El desbloqueig per dades biomètriques no està disponible per uns fitxers del sistema mal configurats." }, "biometricsStatusHelptextNotEnabledLocally": { - "message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.", + "message": "El desbloqueig per dades biomètriques no està disponible perquè no s'ha activat per a $EMAIL$ a l'app d'escriptori de Bitwarden.", "placeholders": { "email": { "content": "$1", @@ -3694,7 +3694,7 @@ } }, "biometricsStatusHelptextUnavailableReasonUnknown": { - "message": "Biometric unlock is currently unavailable for an unknown reason." + "message": "El desbloqueig per dades biomètriques no està disponible ara per motius desconeguts." }, "itemDetails": { "message": "Detalls de l'element" @@ -3724,19 +3724,19 @@ "message": "Denega" }, "sshkeyApprovalTitle": { - "message": "Confirm SSH key usage" + "message": "Confirma l'ús de la clau SSH" }, "agentForwardingWarningTitle": { - "message": "Warning: Agent Forwarding" + "message": "Advertència: Reenviament de l'Agent" }, "agentForwardingWarningText": { - "message": "This request comes from a remote device that you are logged into" + "message": "Aquesta petició ve d'un dispositiu remot on teniu iniciada la sessió" }, "sshkeyApprovalMessageInfix": { - "message": "is requesting access to" + "message": "sol·licita accés a" }, "sshkeyApprovalMessageSuffix": { - "message": "in order to" + "message": "per" }, "sshActionLogin": { "message": "authenticate to a server" diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 11eb2113bd2..a21f9b9258e 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -573,7 +573,7 @@ "message": "Kopírovat ověřovací kód (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopírovat $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "číslo karty" }, "premiumMembership": { "message": "Prémiové členství" diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 87ddaae531a..1f60be15374 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -573,7 +573,7 @@ "message": "Verifizierungscode (TOTP) kopieren" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ kopieren", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -2425,13 +2425,13 @@ "message": "Dein Master-Passwort entspricht nicht einer oder mehreren Richtlinien deiner Organisation. Um auf den Tresor zugreifen zu können, musst du dein Master-Passwort jetzt aktualisieren. Wenn du fortfährst, wirst du von deiner aktuellen Sitzung abgemeldet und musst dich erneut anmelden. Aktive Sitzungen auf anderen Geräten können noch bis zu einer Stunde lang aktiv bleiben." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Nachdem du dein Passwort geändert hast, musst du dich mit deinem neuen Passwort anmelden. Aktive Sitzungen auf anderen Geräten werden innerhalb einer Stunde abgemeldet." }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "Ändere dein Master-Passwort, um die Kontowiederherstellung abzuschließen." }, "updateMasterPasswordSubtitle": { - "message": "Your master password does not meet this organization’s requirements. Change your master password to continue." + "message": "Dein Master-Passwort entspricht nicht den Anforderungen dieser Organisation. Ändere dein Master-Passwort, um fortzufahren." }, "tdeDisabledMasterPasswordRequired": { "message": "Deine Organisation hat die vertrauenswürdige Geräteverschlüsselung deaktiviert. Bitte lege ein Master-Passwort fest, um auf deinen Tresor zuzugreifen." @@ -3174,7 +3174,7 @@ "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Du musst dich auf einem vertrauenswürdigen Gerät anmelden oder deinem Administrator bitten, dir ein Passwort zuzuweisen." }, "region": { "message": "Region" @@ -3568,15 +3568,15 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Die URI-Übereinstimmungserkennung ist die Methode, mit der Bitwarden Auto-Ausfüllen-Vorschläge erkennt.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Regulärer Ausdruck\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Beginnt mit\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { @@ -3584,7 +3584,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Erweiterte Optionen", "description": "Advanced option placeholder for uri option component" }, "warningCapitalized": { @@ -4016,9 +4016,9 @@ } }, "enableAutotype": { - "message": "Enable autotype shortcut" + "message": "Auto-Schreiben Tastenkombination aktivieren" }, "enableAutotypeDescription": { - "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." + "message": "Bitwarden überprüft die Eingabestellen nicht. Vergewissere dich, dass du dich im richtigen Fenster und Feld befindest, bevor du die Tastenkombination verwendest." } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index f5043724cbb..0f5e14596d7 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -573,7 +573,7 @@ "message": "Ellenőrző kód másolása (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ másolása", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "kártya szám" }, "premiumMembership": { "message": "Prémium tagság" diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 9bc5f987b18..19a91367567 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -3697,25 +3697,25 @@ "message": "生体認証によるロック解除は、不明な理由により現在利用できません。" }, "itemDetails": { - "message": "Item details" + "message": "アイテムの詳細" }, "itemName": { - "message": "Item name" + "message": "アイテム名" }, "loginCredentials": { - "message": "Login credentials" + "message": "ログイン資格情報" }, "additionalOptions": { - "message": "Additional options" + "message": "追加のオプション" }, "itemHistory": { - "message": "Item history" + "message": "アイテムの履歴" }, "lastEdited": { - "message": "Last edited" + "message": "直近の編集" }, "upload": { - "message": "Upload" + "message": "アップロード" }, "authorize": { "message": "認可" @@ -3799,10 +3799,10 @@ "message": "移動" }, "newFolder": { - "message": "New folder" + "message": "新しいフォルダー" }, "folderName": { - "message": "Folder Name" + "message": "フォルダー名" }, "folderHintText": { "message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums" diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 7c00a3a40a9..0849bf29b45 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -573,7 +573,7 @@ "message": "Ievietot Apliecinājuma kodu (TOTP) starpliktuvē" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Ievietot starpliktuvē $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "kartes numurs" }, "premiumMembership": { "message": "Premium dalība" diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index a9cb4fcd88e..d3764bad580 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -573,7 +573,7 @@ "message": "Copiar código de verificação (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Copiar $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "número do cartão" }, "premiumMembership": { "message": "Subscrição Premium" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 067570eb002..54fb7f1f643 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -573,7 +573,7 @@ "message": "Kopiera verifieringskod (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopiera $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "kortnummer" }, "premiumMembership": { "message": "Premium-medlemskap" @@ -1506,7 +1506,7 @@ "message": "Lösenordshistorik" }, "generatorHistory": { - "message": "Generatorns historia" + "message": "Generatorns historik" }, "clearGeneratorHistoryTitle": { "message": "Rensa generatorhistorik" diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index d804c03b6cd..d0ff3cca7bc 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -573,7 +573,7 @@ "message": "Sao chép mã xác thực (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Sao chép $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "số thẻ" }, "premiumMembership": { "message": "Thành viên Cao Cấp" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index fe8a61af825..c0bb7e0f0d3 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -573,7 +573,7 @@ "message": "复制验证码 (TOTP)" }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "复制 $FIELD$、$CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "卡号" }, "premiumMembership": { "message": "高级会员" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 541e6e82658..c2253c56760 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -24,7 +24,7 @@ "message": "身分" }, "typeNote": { - "message": "Note" + "message": "備註" }, "typeSecureNote": { "message": "安全筆記" @@ -241,22 +241,22 @@ "message": "SSH代理是一個針對開發者的服務,它能夠直接從 Bitwarden 密碼庫簽發SSH請求。" }, "sshAgentPromptBehavior": { - "message": "Ask for authorization when using SSH agent" + "message": "使用 SSH 代理程式時要求授權" }, "sshAgentPromptBehaviorDesc": { - "message": "Choose how to handle SSH-agent authorization requests." + "message": "選擇如何處理 SSH 代理程式的授權要求。" }, "sshAgentPromptBehaviorHelp": { - "message": "Remember SSH authorizations" + "message": "記住 SSH 授權" }, "sshAgentPromptBehaviorAlways": { - "message": "Always" + "message": "總是" }, "sshAgentPromptBehaviorNever": { - "message": "Never" + "message": "永不" }, "sshAgentPromptBehaviorRememberUntilLock": { - "message": "Remember until vault is locked" + "message": "記住直到密碼庫鎖定為止" }, "premiumRequired": { "message": "需要進階會員資格" @@ -409,16 +409,16 @@ "message": "驗證器金鑰 (TOTP)" }, "authenticatorKey": { - "message": "Authenticator key" + "message": "驗證器金鑰" }, "autofillOptions": { - "message": "Autofill options" + "message": "自動填入選項" }, "websiteUri": { - "message": "Website (URI)" + "message": "網站 (URI)" }, "websiteUriCount": { - "message": "Website (URI) $COUNT$", + "message": "網站 (URI) $COUNT$ 個", "description": "Label for an input field that contains a website URI. The input field is part of a list of fields, and the count indicates the position of the field in the list.", "placeholders": { "count": { @@ -428,43 +428,43 @@ } }, "websiteAdded": { - "message": "Website added" + "message": "已新增網站" }, "addWebsite": { - "message": "Add website" + "message": "新增網站" }, "deleteWebsite": { - "message": "Delete website" + "message": "刪除網站" }, "owner": { - "message": "Owner" + "message": "擁有者" }, "addField": { - "message": "Add field" + "message": "新增欄位" }, "editField": { - "message": "Edit field" + "message": "編輯欄位" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "Are you sure you want to permanently delete this attachment?" + "message": "你確定要永久刪除此附件嗎?" }, "fieldType": { - "message": "Field type" + "message": "欄位類別" }, "fieldLabel": { - "message": "Field label" + "message": "欄位標籤" }, "add": { - "message": "Add" + "message": "新增" }, "textHelpText": { - "message": "Use text fields for data like security questions" + "message": "像安全問題之類的資料 請使用文本框" }, "hiddenHelpText": { - "message": "Use hidden fields for sensitive data like a password" + "message": "敏感資料 如同密碼 使用隱藏字段" }, "checkBoxHelpText": { - "message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email" + "message": "如果您想自動填充表單的復選框,例如「記住電子郵件」,請使用復選框" }, "linkedHelpText": { "message": "Use a linked field when you are experiencing autofill issues for a specific website." @@ -748,10 +748,10 @@ "message": "Enter the code sent to your email" }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Enter the code from your authenticator app" + "message": "請輸入您驗證器應用程式中的代碼" }, "pressYourYubiKeyToAuthenticate": { - "message": "Press your YubiKey to authenticate" + "message": "請輕觸您的 YubiKey 以進行驗證" }, "logInWithPasskey": { "message": "以通行密鑰 (passkey) 登入" @@ -806,7 +806,7 @@ "message": "主密碼提示" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "密碼強度分數 $SCORE$", "placeholders": { "score": { "content": "$1", @@ -921,7 +921,7 @@ "message": "驗證已被取消或時間過長。請再試一次。" }, "openInNewTab": { - "message": "Open in new tab" + "message": "在新分頁開啟" }, "invalidVerificationCode": { "message": "無效的驗證碼" @@ -939,14 +939,14 @@ } }, "dontAskAgainOnThisDeviceFor30Days": { - "message": "Don't ask again on this device for 30 days" + "message": "30 天內不要再於這部裝置上詢問" }, "selectAnotherMethod": { - "message": "Select another method", + "message": "選擇其他方法", "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use your recovery code" + "message": "使用您的復原碼" }, "insertU2f": { "message": "將您的安全鑰匙插入電腦的 USB 連接埠,然後觸摸其按鈕(如有的話)。" @@ -979,13 +979,13 @@ "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "verifyYourIdentity": { - "message": "Verify your Identity" + "message": "驗證您的身分" }, "weDontRecognizeThisDevice": { - "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + "message": "我們無法辨識這部裝置。請輸入傳送到您電子郵件的驗證碼,以驗證您的身分。" }, "continueLoggingIn": { - "message": "Continue logging in" + "message": "繼續登入" }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" @@ -1012,7 +1012,7 @@ "message": "兩步驟登入選項" }, "selectTwoStepLoginMethod": { - "message": "Select two-step login method" + "message": "選取兩步驟登入方式" }, "selfHostedEnvironment": { "message": "自我裝載環境" @@ -1070,7 +1070,7 @@ "message": "否" }, "location": { - "message": "Location" + "message": "位置" }, "overwritePassword": { "message": "覆寫密碼" @@ -1440,7 +1440,7 @@ "description": "Copy credit card security code (CVV)" }, "cardNumber": { - "message": "card number" + "message": "信用卡號碼" }, "premiumMembership": { "message": "進階會員" @@ -1734,10 +1734,10 @@ "message": "帳戶已限制" }, "restrictCardTypeImport": { - "message": "Cannot import card item types" + "message": "無法匯入卡片項目類別" }, "restrictCardTypeImportDesc": { - "message": "A policy set by 1 or more organizations prevents you from importing cards to your vaults." + "message": "由於一或多個組織設有政策,您無法匯入卡片至您的保險庫。" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { "message": "「檔案密碼」與「確認檔案密碼」不一致。" From 462287223ab447913720aec982171f86d8987a2b Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:50:09 +0200 Subject: [PATCH 22/54] Autosync the updated translations (#15691) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/az/messages.json | 62 ++++++++-------- apps/browser/src/_locales/bg/messages.json | 62 ++++++++-------- apps/browser/src/_locales/cs/messages.json | 18 ++--- apps/browser/src/_locales/de/messages.json | 40 +++++------ apps/browser/src/_locales/hu/messages.json | 62 ++++++++-------- apps/browser/src/_locales/lv/messages.json | 62 ++++++++-------- apps/browser/src/_locales/nl/messages.json | 42 +++++------ apps/browser/src/_locales/pl/messages.json | 24 +++---- apps/browser/src/_locales/pt_PT/messages.json | 70 +++++++++---------- apps/browser/src/_locales/ru/messages.json | 14 ++-- apps/browser/src/_locales/sk/messages.json | 14 ++-- apps/browser/src/_locales/sv/messages.json | 18 ++--- apps/browser/src/_locales/vi/messages.json | 64 ++++++++--------- apps/browser/src/_locales/zh_CN/messages.json | 14 ++-- 14 files changed, 283 insertions(+), 283 deletions(-) diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 67019f5cac7..5e7bf056980 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -1830,7 +1830,7 @@ "message": "Güvənlik kodu" }, "cardNumber": { - "message": "card number" + "message": "kart nömrəsi" }, "ex": { "message": "məs." @@ -3464,7 +3464,7 @@ "message": "Tələb göndərildi" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$DEVICE$ cihazında $EMAIL$ üçün giriş tələbi təsdiqləndi", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Başqa bir cihazdan giriş cəhdinə rədd cavabı verdiniz. Bu siz idinizsə, cihazla yenidən giriş etməyə çalışın." }, "device": { - "message": "Device" + "message": "Cihaz" }, "loginStatus": { - "message": "Login status" + "message": "Giriş statusu" }, "masterPasswordChanged": { "message": "Ana parol saxlanıldı" @@ -3582,28 +3582,28 @@ "message": "Gələcək girişləri problemsiz etmək üçün bu cihazı xatırla" }, "manageDevices": { - "message": "Manage devices" + "message": "Cihazları idarə et" }, "currentSession": { - "message": "Current session" + "message": "Hazırkı seans" }, "mobile": { - "message": "Mobile", + "message": "Mobil", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Uzantı", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Masaüstü", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Veb seyf" }, "webApp": { - "message": "Web app" + "message": "Veb tətbiq" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Tələb gözlənir" }, "firstLogin": { - "message": "First login" + "message": "İlk giriş" }, "trusted": { - "message": "Trusted" + "message": "Güvənli" }, "needsApproval": { - "message": "Needs approval" + "message": "Təsdiq lazımdır" }, "devices": { - "message": "Devices" + "message": "Cihazlar" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "$EMAIL$ ilə müraciət cəhdi", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Müraciəti təsdiqlə" }, "denyAccess": { - "message": "Deny access" + "message": "Müraciətə rədd cavabı ver" }, "time": { - "message": "Time" + "message": "Vaxt" }, "deviceType": { - "message": "Device Type" + "message": "Cihaz növü" }, "loginRequest": { - "message": "Login request" + "message": "Giriş tələbi" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Bu tələb artıq yararsızdır." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Hesabınıza müraciət etməyə çalışırsınız?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "$DEVICE$ cihazında $EMAIL$ üçün giriş təsdiqləndi", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Başqa bir cihazdan giriş cəhdinə rədd cavabı verdiniz. Bu həqiqətən siz idinizsə, cihazla yenidən giriş etməyə çalışın." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Giriş tələbinin müddəti artıq bitib." }, "justNow": { - "message": "Just now" + "message": "İndicə" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "$MINUTES$ dəqiqə əvvəl tələb göndərildi", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopyala: $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index b86493d7d5a..672e029a662 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -1830,7 +1830,7 @@ "message": "Код за сигурност" }, "cardNumber": { - "message": "card number" + "message": "номер на карта" }, "ex": { "message": "напр." @@ -3464,7 +3464,7 @@ "message": "Заявката е изпратена" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Заявката за вписване за $EMAIL$ на $DEVICE$ е одобрена", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Вие отказахте опит за вписване от друго устройство. Ако това сте били Вие, опитайте да се впишете от устройството отново." }, "device": { - "message": "Device" + "message": "Устройство" }, "loginStatus": { - "message": "Login status" + "message": "Състояние на вписването" }, "masterPasswordChanged": { "message": "Главната парола е запазена" @@ -3582,28 +3582,28 @@ "message": "Запомняне на това устройство, така че в бъдеще вписването да бъде по-лесно" }, "manageDevices": { - "message": "Manage devices" + "message": "Управление на устройствата" }, "currentSession": { - "message": "Current session" + "message": "Текуща сесия" }, "mobile": { - "message": "Mobile", + "message": "Мобилно приложение", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Добавка за браузър", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Работен плот", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Трезор по уеб" }, "webApp": { - "message": "Web app" + "message": "Приложение по уеб" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Чакаща заявка" }, "firstLogin": { - "message": "First login" + "message": "Първо вписване" }, "trusted": { - "message": "Trusted" + "message": "Доверено" }, "needsApproval": { - "message": "Needs approval" + "message": "Изисква одобрение" }, "devices": { - "message": "Devices" + "message": "Устройства" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Опит за достъп от $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Разрешаване на достъпа" }, "denyAccess": { - "message": "Deny access" + "message": "Отказване на достъпа" }, "time": { - "message": "Time" + "message": "Време" }, "deviceType": { - "message": "Device Type" + "message": "Вид устройство" }, "loginRequest": { - "message": "Login request" + "message": "Заявка за вписване" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Тази заявка вече не е активна." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Опитвате ли се да получите достъп до акаунта си?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Вписването за $EMAIL$ на $DEVICE$ е одобрено", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Вие отказахте опит за вписване от друго устройство. Ако това наистина сте били Вие, опитайте да се впишете от устройството отново." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Заявката за вписване вече е изтекла." }, "justNow": { - "message": "Just now" + "message": "Току-що" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Заявено преди $MINUTES$ минути", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Копиране на $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 86c1b650996..4e0096a1520 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -1830,7 +1830,7 @@ "message": "Bezpečnostní kód" }, "cardNumber": { - "message": "card number" + "message": "číslo karty" }, "ex": { "message": "např." @@ -3480,10 +3480,10 @@ "message": "Pokus o přihlášení byl zamítnut z jiného zařízení. Pokud jste to Vy, zkuste se znovu přihlásit do zařízení." }, "device": { - "message": "Device" + "message": "Zařízení" }, "loginStatus": { - "message": "Login status" + "message": "Stav přihlášení" }, "masterPasswordChanged": { "message": "Hlavní heslo bylo uloženo" @@ -3652,13 +3652,13 @@ "message": "Požadavek na přihlášení" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Tento požadavek již není platný." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Pokoušíte se získat přístup k Vašemu účtu?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Přihlášení bylo potvrzeno z $EMAIL$ pro $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Pokus o přihlášení byl zamítnut z jiného zařízení. Pokud jste to opravdu Vy, zkuste se znovu přihlásit do zařízení." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Požadavek na přihlášení již vypršel." }, "justNow": { "message": "Právě teď" @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopírovat $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 91dfac2e7c0..2e3d9369c41 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -1174,10 +1174,10 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Nachdem du dein Passwort geändert hast, musst du dich mit deinem neuen Passwort anmelden. Aktive Sitzungen auf anderen Geräten werden innerhalb einer Stunde abgemeldet." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Ändere dein Master-Passwort, um die Kontowiederherstellung abzuschließen." }, "enableChangedPasswordNotification": { "message": "Nach dem Aktualisieren bestehender Zugangsdaten fragen" @@ -2929,7 +2929,7 @@ "message": "Du musst deine E-Mail Adresse verifizieren, um diese Funktion nutzen zu können. Du kannst deine E-Mail im Web-Tresor verifizieren." }, "masterPasswordSuccessfullySet": { - "message": "Master-Passwort erfolgreich eingerichtet" + "message": "Master-Passwort erfolgreich festgelegt" }, "updatedMasterPassword": { "message": "Master-Passwort aktualisiert" @@ -3464,7 +3464,7 @@ "message": "Anfrage gesendet" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Anmeldeanfrage für $EMAIL$ auf $DEVICE$ genehmigt", "placeholders": { "email": { "content": "$1", @@ -3477,10 +3477,10 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Du hast einen Anmeldeversuch von einem anderen Gerät abgelehnt. Wenn du das wirklich warst, versuche dich erneut mit dem Gerät anzumelden." }, "device": { - "message": "Device" + "message": "Gerät" }, "loginStatus": { "message": "Anmeldestatus" @@ -3582,17 +3582,17 @@ "message": "Dieses Gerät merken, um zukünftige Anmeldungen reibungslos zu gestalten" }, "manageDevices": { - "message": "Manage devices" + "message": "Geräte verwalten" }, "currentSession": { "message": "Aktuelle Sitzung" }, "mobile": { - "message": "Mobile", + "message": "Mobile App", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Erweiterung", "description": "Browser extension/addon" }, "desktop": { @@ -3613,7 +3613,7 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Anfrage ausstehend" }, "firstLogin": { "message": "Erste Anmeldung" @@ -3625,7 +3625,7 @@ "message": "Benötigt Genehmigung" }, "devices": { - "message": "Devices" + "message": "Geräte" }, "accessAttemptBy": { "message": "Zugriffsversuch von $EMAIL$", @@ -3643,13 +3643,13 @@ "message": "Zugriff ablehnen" }, "time": { - "message": "Time" + "message": "Zeit" }, "deviceType": { "message": "Gerätetyp" }, "loginRequest": { - "message": "Login request" + "message": "Anmeldungsanfrage" }, "thisRequestIsNoLongerValid": { "message": "Diese Anfrage ist nicht mehr gültig." @@ -3710,10 +3710,10 @@ "message": "Admin-Genehmigung anfragen" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Anmeldung kann nicht abgeschlossen werden" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Du musst dich auf einem vertrauenswürdigen Gerät anmelden oder deinem Administrator bitten, dir ein Passwort zuzuweisen." }, "ssoIdentifierRequired": { "message": "SSO-Kennung der Organisation erforderlich." @@ -4395,15 +4395,15 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "Die URI-Übereinstimmungserkennung ist die Methode, mit der Bitwarden Auto-Ausfüllen-Vorschläge erkennt.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Regulärer Ausdruck\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Beginnt mit\" ist eine erweiterte Option mit erhöhtem Risiko der Kompromittierung von Zugangsdaten.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { @@ -4411,7 +4411,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Erweiterte Optionen", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ kopieren", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 228f7ef8c09..b77d613da51 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -1830,7 +1830,7 @@ "message": "Biztonsági Kód" }, "cardNumber": { - "message": "card number" + "message": "kártya szám" }, "ex": { "message": "példa:" @@ -3464,7 +3464,7 @@ "message": "A kérés elküldésre került." }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "A bejelentkezési kérelem jóváhagyásra került: $EMAIL$ - $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Megtagadásra került egy bejelentkezési kísérletet egy másik eszközről. Ha valóban mi voltunk, próbáljunk meg újra bejelentkezni az eszközzel." }, "device": { - "message": "Device" + "message": "Eszköz" }, "loginStatus": { - "message": "Login status" + "message": "Bejelentkezési állapot" }, "masterPasswordChanged": { "message": "A mesterjelszó mentésre került." @@ -3582,28 +3582,28 @@ "message": "Emlékezés az eszközre, hogy zökkenőmentes legyen a jövőbeni bejelentkezés" }, "manageDevices": { - "message": "Manage devices" + "message": "Eszközök kezelése" }, "currentSession": { - "message": "Current session" + "message": "Jelenlegi munkamenet" }, "mobile": { - "message": "Mobile", + "message": "Mobil", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Kiterjesztés", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Asztali", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Webes széf" }, "webApp": { - "message": "Web app" + "message": "Webalkalmazás" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Függőben lévő kérelem" }, "firstLogin": { - "message": "First login" + "message": "Első bejelentkezés" }, "trusted": { - "message": "Trusted" + "message": "Megbízható" }, "needsApproval": { - "message": "Needs approval" + "message": "Jóváhagyást igényel" }, "devices": { - "message": "Devices" + "message": "Eszközök" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Bejelentkezési kísérlet $EMAIL$ segítségével", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Hozzáférés megerősítése" }, "denyAccess": { - "message": "Deny access" + "message": "Hozzáférés megtagadása" }, "time": { - "message": "Time" + "message": "Időpont" }, "deviceType": { - "message": "Device Type" + "message": "Eszköz típus" }, "loginRequest": { - "message": "Login request" + "message": "Bejelentkezés kérés" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "A kérés a továbbiakban már nem érvényes." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "A fiókhoz próbálunk hozzáférni?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "A bejelelentketés $EMAIL$ email címmel megerősítésre került $DEVICE$ eszközön.", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Megtagadásra került egy bejelentkezési kísérletet egy másik eszközről. Ha valóban mi voltunk, próbáljunk meg újra bejelentkezni az eszközzel." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "A bejelentkezési kérés már lejárt." }, "justNow": { - "message": "Just now" + "message": "Éppen most" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Kérve $MINUTES$ perccel ezelőtt", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "$FIELD$, $CIPHERNAME$ másolása", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 9763801b773..ba43b1e5f44 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -1830,7 +1830,7 @@ "message": "Drošības kods" }, "cardNumber": { - "message": "card number" + "message": "kartes numurs" }, "ex": { "message": "piem." @@ -3464,7 +3464,7 @@ "message": "Pieprasījums nosūtīts" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "$EMAIL$ pieteikšanās pieprasījums apstiprināts $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Tu noraidīji pieteikšanās mēģinājumu no citas ierīces. Ja tas biji Tu, mēģini pieteikties no ierīces vēlreiz!" }, "device": { - "message": "Device" + "message": "Ierīce" }, "loginStatus": { - "message": "Login status" + "message": "Pieteikšanās stāvoklis" }, "masterPasswordChanged": { "message": "Galvenā parole saglabāta" @@ -3582,28 +3582,28 @@ "message": "Atcerēties šo ierīci, lai nākotnes pieteikšanos padarītu plūdenāku" }, "manageDevices": { - "message": "Manage devices" + "message": "Pārvaldīt ierīces" }, "currentSession": { - "message": "Current session" + "message": "Pašreizējā sesija" }, "mobile": { - "message": "Mobile", + "message": "Tālrunis", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Paplašinājums", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Darbvirsma", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Tīmekļa glabātava" }, "webApp": { - "message": "Web app" + "message": "Tīmekļa lietotne" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Pieprasījums gaida uz apstrādi" }, "firstLogin": { - "message": "First login" + "message": "Pirmā pieteikšanās" }, "trusted": { - "message": "Trusted" + "message": "Uzticama" }, "needsApproval": { - "message": "Needs approval" + "message": "Nepieciešams apstiprinājums" }, "devices": { - "message": "Devices" + "message": "Ierīces" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "$EMAIL$ piekļuves mēģinājums", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Apstiprināt piekļuvi" }, "denyAccess": { - "message": "Deny access" + "message": "Noraidīt piekļuvi" }, "time": { - "message": "Time" + "message": "Laiks" }, "deviceType": { - "message": "Device Type" + "message": "Ierīces veids" }, "loginRequest": { - "message": "Login request" + "message": "Pieteikšanās pieprasījums" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Šis pieprasījums vairs nav derīgs." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Vai mēģini piekļūt savam kontam?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "$EMAIL$ pieteikšanās apstiprināta ierīcē $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Tu noraidīji pieteikšanās mēģinājumu no citas ierīces. Ja tas tiešām biji Tu, mēģini pieteikties no ierīces vēlreiz!" }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Pieteikšanās pieprasījuma derīgums jau ir beidzies." }, "justNow": { - "message": "Just now" + "message": "Tikko" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Pieprasīts pirms $MINUTES$ minūtēm", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Ievietot starpliktuvē $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index b67df127da0..b5220861652 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -3464,7 +3464,7 @@ "message": "Verzoek verzonden" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Inloggen voor $EMAIL$ goedgekeurd op $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,7 +3477,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Je hebt een inlogpoging vanaf een ander apparaat geweigerd. Als je dit toch echt zelf was, probeer dan opnieuw in te loggen met het apparaat." }, "device": { "message": "Apparaat" @@ -3582,17 +3582,17 @@ "message": "Onthoud dit apparaat om in het vervolg naadloos in te loggen" }, "manageDevices": { - "message": "Manage devices" + "message": "Apparaten beheren" }, "currentSession": { - "message": "Current session" + "message": "Huidige sessie" }, "mobile": { - "message": "Mobile", + "message": "Mobiel", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Extensie", "description": "Browser extension/addon" }, "desktop": { @@ -3600,10 +3600,10 @@ "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Webkluis" }, "webApp": { - "message": "Web app" + "message": "Web-app" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Verzoek in behandeling" }, "firstLogin": { - "message": "First login" + "message": "Eerst inloggen" }, "trusted": { - "message": "Trusted" + "message": "Vertrouwd" }, "needsApproval": { - "message": "Needs approval" + "message": "Heeft goedkeuring nodig" }, "devices": { - "message": "Devices" + "message": "Apparaten" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Inlogpoging door $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,19 +3637,19 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Toegang bevestigen" }, "denyAccess": { - "message": "Deny access" + "message": "Toegang weigeren" }, "time": { - "message": "Time" + "message": "Tijd" }, "deviceType": { - "message": "Device Type" + "message": "Apparaattype" }, "loginRequest": { - "message": "Login request" + "message": "Log-inverzoek" }, "thisRequestIsNoLongerValid": { "message": "Dit verzoek is niet langer geldig." @@ -3677,10 +3677,10 @@ "message": "Inlogverzoek is al verlopen." }, "justNow": { - "message": "Just now" + "message": "Zojuist" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "$MINUTES$ minuten geleden aangevraagd", "placeholders": { "minutes": { "content": "$1", diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 23221633916..aa8ab76d543 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -1761,10 +1761,10 @@ "message": "Pokaż ikony stron internetowych" }, "faviconDesc": { - "message": "Pokaż rozpoznawalny obraz obok danych logowania." + "message": "Pokaż rozpoznawalną ikonę obok danych logowania." }, "faviconDescAlt": { - "message": "Pokaż rozpoznawalny obraz obok danych logowania. Dotyczy wszystkich zalogowanych kont." + "message": "Pokaż rozpoznawalną ikonę obok danych logowania. Dotyczy wszystkich zalogowanych kont." }, "enableBadgeCounter": { "message": "Pokaż licznik na ikonie" @@ -3464,7 +3464,7 @@ "message": "Prośba została wysłana" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Logowanie potwierdzone dla $EMAIL$ na $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Odrzucono próby logowania z innego urządzenia. Jeśli to naprawdę Ty, spróbuj ponownie zalogować się za pomocą urządzenia." }, "device": { "message": "Urządzenie" }, "loginStatus": { - "message": "Login status" + "message": "Status zalogowania" }, "masterPasswordChanged": { "message": "Hasło główne zostało zapisane" @@ -3613,7 +3613,7 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Zapytanie oczekuje" }, "firstLogin": { "message": "Pierwsze logowanie" @@ -3628,7 +3628,7 @@ "message": "Urządzenia" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Próba dostępu przez $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3652,13 +3652,13 @@ "message": "Żądanie logowania" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Prośba nie jest już ważna." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Czy próbujesz uzyskać dostęp do swojego konta?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Logowanie potwierdzone dla $EMAIL$ na $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Odrzucono próby logowania z innego urządzenia. Jeśli to naprawdę Ty, spróbuj ponownie zalogować się za pomocą urządzenia." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Prośba logowania wygasła." }, "justNow": { "message": "Teraz" diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index b31a7797df3..1e77e1c3035 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -450,7 +450,7 @@ "message": "Gera automaticamente palavras-passe fortes e únicas para as suas credenciais." }, "bitWebVaultApp": { - "message": "Aplicação Web Bitwarden" + "message": "Aplicação web Bitwarden" }, "importItems": { "message": "Importar itens" @@ -653,7 +653,7 @@ "message": "Classificar a extensão" }, "browserNotSupportClipboard": { - "message": "O seu navegador Web não suporta a cópia fácil da área de transferência. Em vez disso, copie manualmente." + "message": "O seu navegador web não suporta a cópia fácil da área de transferência. Em vez disso, copie manualmente." }, "verifyYourIdentity": { "message": "Verifique a sua identidade" @@ -929,7 +929,7 @@ "message": "Torne a sua conta mais segura configurando a verificação de dois passos na aplicação Web Bitwarden." }, "twoStepLoginConfirmationTitle": { - "message": "Continuar para a aplicação Web?" + "message": "Continuar para a aplicação web?" }, "editedFolder": { "message": "Pasta guardada" @@ -1584,7 +1584,7 @@ "message": "URL do servidor da API" }, "webVaultUrl": { - "message": "URL do servidor do cofre Web" + "message": "URL do servidor do cofre web" }, "identityUrl": { "message": "URL do servidor de identidade" @@ -1830,7 +1830,7 @@ "message": "Código de segurança" }, "cardNumber": { - "message": "card number" + "message": "número do cartão" }, "ex": { "message": "ex." @@ -3464,7 +3464,7 @@ "message": "Pedido enviado" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Pedido de início de sessão aprovado para $EMAIL$ no $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Recusou uma tentativa de início de sessão de outro dispositivo. Se foi realmente o caso, tente iniciar sessão com o dispositivo novamente." }, "device": { - "message": "Device" + "message": "Dispositivo" }, "loginStatus": { - "message": "Login status" + "message": "Estado do início de sessão" }, "masterPasswordChanged": { "message": "Palavra-passe mestra guardada" @@ -3582,28 +3582,28 @@ "message": "Memorizar este dispositivo para facilitar futuros inícios de sessão" }, "manageDevices": { - "message": "Manage devices" + "message": "Gerir dispositivos" }, "currentSession": { - "message": "Current session" + "message": "Sessão atual" }, "mobile": { - "message": "Mobile", + "message": "Móvel", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Extensão", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Computador", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Cofre web" }, "webApp": { - "message": "Web app" + "message": "Aplicação web" }, "cli": { "message": "CLI" @@ -3613,22 +3613,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Pedido pendente" }, "firstLogin": { - "message": "First login" + "message": "Primeiro início de sessão" }, "trusted": { - "message": "Trusted" + "message": "Confiável" }, "needsApproval": { - "message": "Needs approval" + "message": "Precisa de aprovação" }, "devices": { - "message": "Devices" + "message": "Dispositivos" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Tentativa de acesso por $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Confirmar acesso" }, "denyAccess": { - "message": "Deny access" + "message": "Recusar acesso" }, "time": { - "message": "Time" + "message": "Hora" }, "deviceType": { - "message": "Device Type" + "message": "Tipo de dispositivo" }, "loginRequest": { - "message": "Login request" + "message": "Pedido de início de sessão" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Este pedido já não é válido." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Está a tentar aceder à sua conta?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Início de sessão confirmado para $EMAIL$ no $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Recusou uma tentativa de início de sessão de outro dispositivo. Se foi realmente o caso, tente iniciar sessão com o dispositivo novamente." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "O pedido de início de sessão já expirou." }, "justNow": { - "message": "Just now" + "message": "Agora mesmo" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Pedido há $MINUTES$ minutos", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Copiar $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 31e331fadad..db236cbfcc8 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -3480,10 +3480,10 @@ "message": "Вы отклонили попытку авторизации с другого устройства. Если это были вы, попробуйте авторизоваться с этого устройства еще раз." }, "device": { - "message": "Device" + "message": "Устройство" }, "loginStatus": { - "message": "Login status" + "message": "Статус авторизации" }, "masterPasswordChanged": { "message": "Мастер-пароль сохранен" @@ -3652,13 +3652,13 @@ "message": "Запрос на вход" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Этот запрос больше не действителен." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Вы пытаетесь получить доступ к своему аккаунту?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Вход подтвержден для $EMAIL$ на $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Вы отклонили попытку авторизации с другого устройства. Если это действительно были вы, попробуйте авторизоваться с этого устройства еще раз." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Запрос на вход истек." }, "justNow": { "message": "Только что" diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 7285399af73..e98c643edb9 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -3480,10 +3480,10 @@ "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli vy, skúste sa prihlásiť pomocou zariadenia znova." }, "device": { - "message": "Device" + "message": "Zariadenie" }, "loginStatus": { - "message": "Login status" + "message": "Stav prihlásenia" }, "masterPasswordChanged": { "message": "Hlavné heslo uložené" @@ -3652,13 +3652,13 @@ "message": "Žiadosť o prihlásenie" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Táto žiadosť už nie je platná." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Snažíte sa získať prístup k svojmu účtu?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Potvrdené prihlásenie pre $EMAIL$ na $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Odmietli ste pokus o prihlásenie z iného zariadenia. Ak ste to boli naozaj vy, skúste sa prihlásiť pomocou zariadenia znova." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Platnosť žiadosti o prihlásenie už vypršala." }, "justNow": { "message": "Práve teraz" diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 725497cc26b..cbfc3e478f5 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -1830,7 +1830,7 @@ "message": "Säkerhetskod" }, "cardNumber": { - "message": "card number" + "message": "kortnummer" }, "ex": { "message": "t. ex." @@ -3480,10 +3480,10 @@ "message": "Du nekade ett inloggningsförsök från en annan enhet. Om det var du, försök att logga in med enheten igen." }, "device": { - "message": "Device" + "message": "Enhet" }, "loginStatus": { - "message": "Login status" + "message": "Inloggningsstatus" }, "masterPasswordChanged": { "message": "Huvudlösenordet sparades" @@ -3652,13 +3652,13 @@ "message": "Begäran om inloggning" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Denna begäran är inte längre giltig." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Försöker du komma åt ditt konto?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Inloggning bekräftad för $EMAIL$ på $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Du har avvisat ett inloggningsförsök från en annan enhet. Om det verkligen var du, försök logga in med enheten igen." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Inloggningsbegäran har redan gått ut." }, "justNow": { "message": "Just nu" @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopiera $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 7aa8348c6b8..b5de1c2981c 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -1830,7 +1830,7 @@ "message": "Mã bảo mật" }, "cardNumber": { - "message": "card number" + "message": "số thẻ" }, "ex": { "message": "Ví dụ:" @@ -3464,7 +3464,7 @@ "message": "Đã gửi yêu cầu" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Đã phê duyệt yêu cầu đăng nhập cho $EMAIL$ trên $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3477,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Bạn đã từ chối một lần đăng nhập từ thiết bị khác. Nếu đó là bạn, hãy thử đăng nhập lại bằng thiết bị đó." }, "device": { - "message": "Device" + "message": "Thiết bị" }, "loginStatus": { - "message": "Login status" + "message": "Trạng thái đăng nhập" }, "masterPasswordChanged": { "message": "Đã lưu mật khẩu chính" @@ -3582,53 +3582,53 @@ "message": "Nhớ thiết bị này để đăng nhập dễ dàng trong tương lai" }, "manageDevices": { - "message": "Manage devices" + "message": "Quản lý thiết bị" }, "currentSession": { - "message": "Current session" + "message": "Phiên hiện tại" }, "mobile": { - "message": "Mobile", + "message": "Di động", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Tiện ích mở rộng", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Máy tính", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Kho web" }, "webApp": { - "message": "Web app" + "message": "Ứng dụng web" }, "cli": { - "message": "CLI" + "message": "Giao diện dòng lệnh (CLI)" }, "sdk": { "message": "SDK", "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Yêu cầu đang chờ xử lý" }, "firstLogin": { - "message": "First login" + "message": "Đăng nhập lần đầu" }, "trusted": { - "message": "Trusted" + "message": "Tin tưởng" }, "needsApproval": { - "message": "Needs approval" + "message": "Cần phê duyệt" }, "devices": { - "message": "Devices" + "message": "Thiết bị" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Cố gắng truy cập bởi $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3637,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Xác nhận truy cập" }, "denyAccess": { - "message": "Deny access" + "message": "Từ chối truy cập" }, "time": { - "message": "Time" + "message": "Thời gian" }, "deviceType": { - "message": "Device Type" + "message": "Loại thiết bị" }, "loginRequest": { - "message": "Login request" + "message": "Yêu cầu đăng nhập" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Yêu cầu này không còn hiệu lực." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Bạn đang cố gắng truy cập tài khoản của mình?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Đã xác nhận đăng nhập cho $EMAIL$ trên $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3671,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Bạn đã từ chối một lần đăng nhập từ thiết bị khác. Nếu thực sự là bạn, hãy thử đăng nhập lại bằng thiết bị đó." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Yêu cầu đăng nhập đã hết hạn." }, "justNow": { - "message": "Just now" + "message": "Vừa xong" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Đã yêu cầu $MINUTES$ phút trước", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4598,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Sao chép $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 9b7d460261a..f44265425e9 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -3480,10 +3480,10 @@ "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, "device": { - "message": "Device" + "message": "设备" }, "loginStatus": { - "message": "Login status" + "message": "登录状态" }, "masterPasswordChanged": { "message": "主密码已保存" @@ -3652,13 +3652,13 @@ "message": "Login request" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "此请求已失效。" }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "您正在尝试访问您的账户吗?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "已确认 $EMAIL$ 在 $DEVICE$ 上的登录", "placeholders": { "email": { "content": "$1", @@ -3671,10 +3671,10 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "您拒绝了另一台设备的登录尝试。如果真的是您,请尝试再次使用该设备登录。" }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "登录请求已过期。" }, "justNow": { "message": "Just now" From 8dc97ca1a7038a14981aa3176ae41eb835fa4022 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Sun, 20 Jul 2025 18:53:10 -0500 Subject: [PATCH 23/54] [PM-20128] Update Claimed Domains description (#15630) * chore: update claimed domain description width, refs PM-20128 * chore: add new message key, delete old message, update reference to new key, refs PM-20128 * chore: change width to max width for claimed domains description, refs PM-20128 --- apps/web/src/locales/en/messages.json | 4 ++-- .../domain-verification/domain-verification.component.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4c4a97e6404..d5ded3c75ea 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10400,8 +10400,8 @@ "domainStatusUnderVerification": { "message": "Under verification" }, - "claimedDomainsDesc": { - "message": "Claim a domain to own all member accounts whose email address matches the domain. Members will be able to skip the SSO identifier when logging in. Administrators will also be able to delete member accounts." + "claimedDomainsDescription": { + "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." }, "invalidDomainNameClaimMessage": { "message": "Input is not a valid format. Format: mydomain.com. Subdomains require separate entries to be claimed." diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html index 4b8d916f776..20afe902b73 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html @@ -4,8 +4,8 @@ </button> </app-header> -<p bitTypography="body1" class="tw-text-main tw-w-2/5"> - {{ "claimedDomainsDesc" | i18n }} +<p bitTypography="body1" class="tw-text-main tw-max-w-4xl"> + {{ "claimedDomainsDescription" | i18n }} <a bitLink target="_blank" From 8b5e6adc376d21552137e31ca63b8939f343f447 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Mon, 21 Jul 2025 15:52:38 +0200 Subject: [PATCH 24/54] [PM-21378] Switch encrypt service to use SDK functions (#14538) * Add new encrypt service functions * Undo changes * Cleanup * Fix build * Fix comments * Switch encrypt service to use SDK functions * Move remaining functions to PureCrypto * Tests * Increase test coverage * Enforce sdk.ready and drop unused codepaths * Delete unused code * Add forgotten sdk init logic * Fix build error * Fix browser extension failing to unlock after process reload due to outdated usage of decryptString * Fix send encryption * Fix client key half decryption being stuck * Attempt to fix sharereplay * Fix build * Fix type / add filter / add distinctuntilchange * Fix capitalization --- .../local-backed-session-storage.service.ts | 8 +- apps/desktop/src/main.ts | 7 + .../src/services/main-sdk-load-service.ts | 9 + apps/desktop/webpack.main.js | 3 + .../abstractions/crypto-function.service.ts | 1 - .../crypto/abstractions/encrypt.service.ts | 14 - ...bulk-encrypt.service.impementation.spec.ts | 170 ---- .../bulk-encrypt.service.implementation.ts | 162 +--- .../encrypt.service.implementation.ts | 424 +++------- .../crypto/services/encrypt.service.spec.ts | 729 ++++-------------- .../fallback-bulk-encrypt.service.spec.ts | 97 --- .../services/fallback-bulk-encrypt.service.ts | 20 +- ...ead-encrypt.service.implementation.spec.ts | 123 --- ...tithread-encrypt.service.implementation.ts | 99 +-- .../web-crypto-function.service.spec.ts | 42 - .../services/web-crypto-function.service.ts | 8 - .../src/tools/send/services/send.service.ts | 3 + libs/key-management/src/key.service.ts | 15 +- 18 files changed, 306 insertions(+), 1628 deletions(-) create mode 100644 apps/desktop/src/services/main-sdk-load-service.ts delete mode 100644 libs/common/src/key-management/crypto/services/bulk-encrypt.service.impementation.spec.ts delete mode 100644 libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.spec.ts delete mode 100644 libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.spec.ts diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index ee5a53b0ad2..978b993fa4d 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -118,14 +118,14 @@ export class LocalBackedSessionStorageService return null; } - const valueJson = await this.encryptService.decryptString(new EncString(local), encKey); - if (valueJson == null) { + try { + const valueJson = await this.encryptService.decryptString(new EncString(local), encKey); + return JSON.parse(valueJson); + } catch { // error with decryption, value is lost, delete state and start over await this.localStorage.remove(this.sessionStorageKey(key)); return null; } - - return JSON.parse(valueJson); } private async updateLocalSessionValue(key: string, value: unknown): Promise<void> { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8c92131617a..eee5e3c84f2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,6 +12,7 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac import { ClientType } from "@bitwarden/common/enums"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { RegionConfig } from "@bitwarden/common/platform/abstractions/environment.service"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { Message, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- For dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; @@ -58,6 +59,7 @@ import { EphemeralValueStorageService } from "./platform/services/ephemeral-valu import { I18nMainService } from "./platform/services/i18n.main.service"; import { SSOLocalhostCallbackService } from "./platform/services/sso-localhost-callback.service"; import { ElectronMainMessagingService } from "./services/electron-main-messaging.service"; +import { MainSdkLoadService } from "./services/main-sdk-load-service"; import { isMacAppStore } from "./utils"; export class Main { @@ -88,6 +90,7 @@ export class Main { desktopAutofillSettingsService: DesktopAutofillSettingsService; versionMain: VersionMain; sshAgentService: MainSshAgentService; + sdkLoadService: SdkLoadService; mainDesktopAutotypeService: MainDesktopAutotypeService; constructor() { @@ -144,6 +147,8 @@ export class Main { this.i18nService = new I18nMainService("en", "./locales/", globalStateProvider); + this.sdkLoadService = new MainSdkLoadService(); + this.mainCryptoFunctionService = new NodeCryptoFunctionService(); const stateEventRegistrarService = new StateEventRegistrarService( @@ -386,6 +391,8 @@ export class Main { this.windowMain.win.on("minimize", () => { this.messagingService.send("windowHidden"); }); + + await this.sdkLoadService.loadAndInit(); }, (e: any) => { this.logService.error("Error while running migrations:", e); diff --git a/apps/desktop/src/services/main-sdk-load-service.ts b/apps/desktop/src/services/main-sdk-load-service.ts new file mode 100644 index 00000000000..847505f68fe --- /dev/null +++ b/apps/desktop/src/services/main-sdk-load-service.ts @@ -0,0 +1,9 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import * as sdk from "@bitwarden/sdk-internal"; + +export class MainSdkLoadService extends SdkLoadService { + async load(): Promise<void> { + const module = await import("@bitwarden/sdk-internal/bitwarden_wasm_internal_bg.wasm"); + (sdk as any).init(module); + } +} diff --git a/apps/desktop/webpack.main.js b/apps/desktop/webpack.main.js index 25a68d8c867..166fba95d52 100644 --- a/apps/desktop/webpack.main.js +++ b/apps/desktop/webpack.main.js @@ -65,6 +65,9 @@ const main = { }, ], }, + experiments: { + asyncWebAssembly: true, + }, plugins: [ new CopyWebpackPlugin({ patterns: [ diff --git a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts index d9541481083..5e4fa86a684 100644 --- a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts @@ -41,7 +41,6 @@ export abstract class CryptoFunctionService { algorithm: "sha1" | "sha256" | "sha512", ): Promise<Uint8Array | string>; abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>; - abstract aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise<Uint8Array>; abstract aesDecryptFastParameters( data: string, iv: string, diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 93d9cb64ce1..67db3591e74 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -7,20 +7,6 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { EncString } from "../models/enc-string"; export abstract class EncryptService { - /** - * @deprecated - * Encrypts a string or Uint8Array to an EncString - * @param plainValue - The value to encrypt - * @param key - The key to encrypt the value with - */ - abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>; - /** - * @deprecated - * Encrypts a value to a Uint8Array - * @param plainValue - The value to encrypt - * @param key - The key to encrypt the value with - */ - abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>; /** * @deprecated * Decrypts an EncString to a string diff --git a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.impementation.spec.ts b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.impementation.spec.ts deleted file mode 100644 index bc77cfb410f..00000000000 --- a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.impementation.spec.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import * as rxjs from "rxjs"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { LogService } from "../../../platform/abstractions/log.service"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { CryptoFunctionService } from "../abstractions/crypto-function.service"; -import { buildSetConfigMessage } from "../types/worker-command.type"; - -import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation"; - -describe("BulkEncryptServiceImplementation", () => { - const cryptoFunctionService = mock<CryptoFunctionService>(); - const logService = mock<LogService>(); - - let sut: BulkEncryptServiceImplementation; - - beforeEach(() => { - sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("decryptItems", () => { - const key = mock<SymmetricCryptoKey>(); - const serverConfig = mock<ServerConfig>(); - const mockWorker = mock<Worker>(); - let globalWindow: any; - - beforeEach(() => { - globalWindow = global.window; - - // Mock creating a worker. - global.Worker = jest.fn().mockImplementation(() => mockWorker); - global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL; - global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url"); - global.URL.revokeObjectURL = jest.fn(); - global.URL.canParse = jest.fn().mockReturnValue(true); - - // Mock the workers returned response. - const mockMessageEvent = { - id: "mock-guid", - data: ["decrypted1", "decrypted2"], - }; - const mockMessageEvent$ = rxjs.from([mockMessageEvent]); - jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$); - }); - - afterEach(() => { - global.window = globalWindow; - }); - - it("throws error if key is null", async () => { - const nullKey = null as unknown as SymmetricCryptoKey; - await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided."); - }); - - it("returns an empty array when items is null", async () => { - const result = await sut.decryptItems(null as any, key); - expect(result).toEqual([]); - }); - - it("returns an empty array when items is empty", async () => { - const result = await sut.decryptItems([], key); - expect(result).toEqual([]); - }); - - it("decrypts items sequentially when window is undefined", async () => { - // Make global window undefined. - delete (global as any).window; - - const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")]; - - const result = await sut.decryptItems(mockItems, key); - - expect(logService.info).toHaveBeenCalledWith( - "Window not available in BulkEncryptService, decrypting sequentially", - ); - expect(result).toEqual(["item1", "item2"]); - expect(mockItems[0].decrypt).toHaveBeenCalledWith(key); - expect(mockItems[1].decrypt).toHaveBeenCalledWith(key); - }); - - it("uses workers for decryption when window is available", async () => { - const mockDecryptedItems = ["decrypted1", "decrypted2"]; - jest - .spyOn<any, any>(sut, "getDecryptedItemsFromWorkers") - .mockResolvedValue(mockDecryptedItems); - - const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")]; - - const result = await sut.decryptItems(mockItems, key); - - expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key); - expect(result).toEqual(mockDecryptedItems); - }); - - it("creates new worker when none exist", async () => { - (sut as any).currentServerConfig = undefined; - const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")]; - - await sut.decryptItems(mockItems, key); - - expect(global.Worker).toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(1); - expect(mockWorker.postMessage).not.toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - - it("sends a SetConfigMessage to the new worker when there is a current server config", async () => { - (sut as any).currentServerConfig = serverConfig; - const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")]; - - await sut.decryptItems(mockItems, key); - - expect(global.Worker).toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(2); - expect(mockWorker.postMessage).toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - - it("does not create worker if one exists", async () => { - (sut as any).currentServerConfig = serverConfig; - (sut as any).workers = [mockWorker]; - const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")]; - - await sut.decryptItems(mockItems, key); - - expect(global.Worker).not.toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(1); - expect(mockWorker.postMessage).not.toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - }); - - describe("onServerConfigChange", () => { - it("updates internal currentServerConfig to new config", () => { - const newConfig = mock<ServerConfig>(); - - sut.onServerConfigChange(newConfig); - - expect((sut as any).currentServerConfig).toBe(newConfig); - }); - - it("does send a SetConfigMessage to workers when there is a worker", () => { - const newConfig = mock<ServerConfig>(); - const mockWorker = mock<Worker>(); - (sut as any).workers = [mockWorker]; - - sut.onServerConfigChange(newConfig); - - expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig })); - }); - }); -}); - -function createMockDecryptable<T extends InitializerMetadata>( - returnValue: any, -): MockProxy<Decryptable<T>> { - const mockDecryptable = mock<Decryptable<T>>(); - mockDecryptable.decrypt.mockResolvedValue(returnValue); - return mockDecryptable; -} diff --git a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts index 7421ae1eb20..1bc1827a07a 100644 --- a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts @@ -1,38 +1,19 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom, fromEvent, filter, map, takeUntil, defaultIfEmpty, Subject } from "rxjs"; -import { Jsonify } from "type-fest"; - import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer"; -import { - DefaultFeatureFlagValue, - FeatureFlag, - getFeatureFlagValue, -} from "../../../enums/feature-flag.enum"; +import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type"; - -// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive -const workerTTL = 60000; // 1 minute -const maxWorkers = 8; -const minNumberOfItemsForMultithreading = 400; +/** + * @deprecated Will be deleted in an immediate subsequent PR + */ export class BulkEncryptServiceImplementation implements BulkEncryptService { - private workers: Worker[] = []; - private timeout: any; - private currentServerConfig: ServerConfig | undefined = undefined; protected useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption]; - private clear$ = new Subject<void>(); - constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, @@ -54,139 +35,12 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService { return []; } - if (typeof window === "undefined" || this.useSDKForDecryption) { - this.logService.info("Window not available in BulkEncryptService, decrypting sequentially"); - const results = []; - for (let i = 0; i < items.length; i++) { - results.push(await items[i].decrypt(key)); - } - return results; - } - - const decryptedItems = await this.getDecryptedItemsFromWorkers(items, key); - return decryptedItems; - } - - onServerConfigChange(newConfig: ServerConfig): void { - this.currentServerConfig = newConfig; - this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption); - this.updateWorkerServerConfigs(newConfig); - } - - /** - * Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items - * faster without interrupting other operations (e.g. updating UI). - */ - private async getDecryptedItemsFromWorkers<T extends InitializerMetadata>( - items: Decryptable<T>[], - key: SymmetricCryptoKey, - ): Promise<T[]> { - if (items == null || items.length < 1) { - return []; - } - - this.clearTimeout(); - - const hardwareConcurrency = navigator.hardwareConcurrency || 1; - let numberOfWorkers = Math.min(hardwareConcurrency, maxWorkers); - if (items.length < minNumberOfItemsForMultithreading) { - numberOfWorkers = 1; - } - - this.logService.info( - `Starting decryption using multithreading with ${numberOfWorkers} workers for ${items.length} items`, - ); - - if (this.workers.length == 0) { - for (let i = 0; i < numberOfWorkers; i++) { - this.workers.push( - new Worker( - new URL( - /* webpackChunkName: 'encrypt-worker' */ - "@bitwarden/common/key-management/crypto/services/encrypt.worker.ts", - import.meta.url, - ), - ), - ); - } - if (this.currentServerConfig != undefined) { - this.updateWorkerServerConfigs(this.currentServerConfig); - } - } - - const itemsPerWorker = Math.floor(items.length / this.workers.length); const results = []; - - for (const [i, worker] of this.workers.entries()) { - const start = i * itemsPerWorker; - const end = start + itemsPerWorker; - const itemsForWorker = items.slice(start, end); - - // push the remaining items to the last worker - if (i == this.workers.length - 1) { - itemsForWorker.push(...items.slice(end)); - } - - const id = Utils.newGuid(); - const request = buildDecryptMessage({ - id, - items: itemsForWorker, - key: key, - }); - - worker.postMessage(request); - results.push( - firstValueFrom( - fromEvent(worker, "message").pipe( - filter((response: MessageEvent) => response.data?.id === id), - map((response) => JSON.parse(response.data.items)), - map((items) => - items.map((jsonItem: Jsonify<T>) => { - const initializer = getClassInitializer<T>(jsonItem.initializerKey); - return initializer(jsonItem); - }), - ), - takeUntil(this.clear$), - defaultIfEmpty([]), - ), - ), - ); + for (let i = 0; i < items.length; i++) { + results.push(await items[i].decrypt(key)); } - - const decryptedItems = (await Promise.all(results)).flat(); - this.logService.info( - `Finished decrypting ${decryptedItems.length} items using ${numberOfWorkers} workers`, - ); - - this.restartTimeout(); - - return decryptedItems; + return results; } - private updateWorkerServerConfigs(newConfig: ServerConfig) { - this.workers.forEach((worker) => { - const request = buildSetConfigMessage({ newConfig }); - worker.postMessage(request); - }); - } - - private clear() { - this.clear$.next(); - for (const worker of this.workers) { - worker.terminate(); - } - this.workers = []; - this.clearTimeout(); - } - - private restartTimeout() { - this.clearTimeout(); - this.timeout = setTimeout(() => this.clear(), workerTTL); - } - - private clearTimeout() { - if (this.timeout != null) { - clearTimeout(this.timeout); - } - } + onServerConfigChange(newConfig: ServerConfig): void {} } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index a68ecd2db54..3e36fd334ec 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -3,36 +3,20 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - EncryptionType, - encryptionTypeToString as encryptionTypeName, -} from "@bitwarden/common/platform/enums"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted"; import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object"; -import { - Aes256CbcHmacKey, - Aes256CbcKey, - SymmetricCryptoKey, -} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PureCrypto } from "@bitwarden/sdk-internal"; -import { - DefaultFeatureFlagValue, - FeatureFlag, - getFeatureFlagValue, -} from "../../../enums/feature-flag.enum"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { - protected useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption]; - private blockType0: boolean = DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0]; - constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, @@ -41,27 +25,40 @@ export class EncryptServiceImplementation implements EncryptService { // Proxy functions; Their implementation are temporary before moving at this level to the SDK async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> { - return this.encrypt(plainValue, key); + if (plainValue == null) { + this.logService.warning( + "[EncryptService] WARNING: encryptString called with null value. Returning null, but this behavior is deprecated and will be removed.", + ); + return null; + } + + await SdkLoadService.Ready; + return new EncString(PureCrypto.symmetric_encrypt_string(plainValue, key.toEncoded())); } async encryptBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncString> { - return this.encrypt(plainValue, key); + await SdkLoadService.Ready; + return new EncString(PureCrypto.symmetric_encrypt_bytes(plainValue, key.toEncoded())); } async encryptFileData(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer> { - return this.encryptToBytes(plainValue, key); + await SdkLoadService.Ready; + return new EncArrayBuffer(PureCrypto.symmetric_encrypt_filedata(plainValue, key.toEncoded())); } async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> { - return this.decryptToUtf8(encString, key); + await SdkLoadService.Ready; + return PureCrypto.symmetric_decrypt_string(encString.encryptedString, key.toEncoded()); } async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> { - return this.decryptToBytes(encString, key); + await SdkLoadService.Ready; + return PureCrypto.symmetric_decrypt_bytes(encString.encryptedString, key.toEncoded()); } async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> { - return this.decryptToBytes(encBuffer, key); + await SdkLoadService.Ready; + return PureCrypto.symmetric_decrypt_filedata(encBuffer.buffer, key.toEncoded()); } async wrapDecapsulationKey( @@ -76,7 +73,10 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for wrapping."); } - return await this.encryptUint8Array(decapsulationKeyPkcs8, wrappingKey); + await SdkLoadService.Ready; + return new EncString( + PureCrypto.wrap_decapsulation_key(decapsulationKeyPkcs8, wrappingKey.toEncoded()), + ); } async wrapEncapsulationKey( @@ -91,7 +91,10 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for wrapping."); } - return await this.encryptUint8Array(encapsulationKeySpki, wrappingKey); + await SdkLoadService.Ready; + return new EncString( + PureCrypto.wrap_encapsulation_key(encapsulationKeySpki, wrappingKey.toEncoded()), + ); } async wrapSymmetricKey( @@ -106,26 +109,61 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for wrapping."); } - return await this.encryptUint8Array(keyToBeWrapped.toEncoded(), wrappingKey); + await SdkLoadService.Ready; + return new EncString( + PureCrypto.wrap_symmetric_key(keyToBeWrapped.toEncoded(), wrappingKey.toEncoded()), + ); } async unwrapDecapsulationKey( wrappedDecapsulationKey: EncString, wrappingKey: SymmetricCryptoKey, ): Promise<Uint8Array> { - return this.decryptBytes(wrappedDecapsulationKey, wrappingKey); + if (wrappedDecapsulationKey == null) { + throw new Error("No wrappedDecapsulationKey provided for unwrapping."); + } + if (wrappingKey == null) { + throw new Error("No wrappingKey provided for unwrapping."); + } + + await SdkLoadService.Ready; + return PureCrypto.unwrap_decapsulation_key( + wrappedDecapsulationKey.encryptedString, + wrappingKey.toEncoded(), + ); } async unwrapEncapsulationKey( wrappedEncapsulationKey: EncString, wrappingKey: SymmetricCryptoKey, ): Promise<Uint8Array> { - return this.decryptBytes(wrappedEncapsulationKey, wrappingKey); + if (wrappedEncapsulationKey == null) { + throw new Error("No wrappedEncapsulationKey provided for unwrapping."); + } + if (wrappingKey == null) { + throw new Error("No wrappingKey provided for unwrapping."); + } + + await SdkLoadService.Ready; + return PureCrypto.unwrap_encapsulation_key( + wrappedEncapsulationKey.encryptedString, + wrappingKey.toEncoded(), + ); } async unwrapSymmetricKey( keyToBeUnwrapped: EncString, wrappingKey: SymmetricCryptoKey, ): Promise<SymmetricCryptoKey> { - return new SymmetricCryptoKey(await this.decryptBytes(keyToBeUnwrapped, wrappingKey)); + if (keyToBeUnwrapped == null) { + throw new Error("No keyToBeUnwrapped provided for unwrapping."); + } + if (wrappingKey == null) { + throw new Error("No wrappingKey provided for unwrapping."); + } + + await SdkLoadService.Ready; + return new SymmetricCryptoKey( + PureCrypto.unwrap_symmetric_key(keyToBeUnwrapped.encryptedString, wrappingKey.toEncoded()), + ); } async hash(value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512"): Promise<string> { @@ -134,261 +172,33 @@ export class EncryptServiceImplementation implements EncryptService { } // Handle updating private properties to turn on/off feature flags. - onServerConfigChange(newConfig: ServerConfig): void { - const oldFlagValue = this.useSDKForDecryption; - this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption); - this.logService.debug( - "[EncryptService] Updated sdk decryption flag", - oldFlagValue, - this.useSDKForDecryption, - ); - this.blockType0 = getFeatureFlagValue(newConfig, FeatureFlag.PM17987_BlockType0); - } - - async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> { - if (key == null) { - throw new Error("No encryption key provided."); - } - - if (this.blockType0) { - if (key.inner().type === EncryptionType.AesCbc256_B64) { - throw new Error("Type 0 encryption is not supported."); - } - } - - if (plainValue == null) { - return Promise.resolve(null); - } - - if (typeof plainValue === "string") { - return this.encryptUint8Array(Utils.fromUtf8ToArray(plainValue), key); - } else { - return this.encryptUint8Array(plainValue, key); - } - } - - private async encryptUint8Array( - plainValue: Uint8Array, - key: SymmetricCryptoKey, - ): Promise<EncString> { - if (key == null) { - throw new Error("No encryption key provided."); - } - - if (this.blockType0) { - if (key.inner().type === EncryptionType.AesCbc256_B64) { - throw new Error("Type 0 encryption is not supported."); - } - } - - if (plainValue == null) { - return Promise.resolve(null); - } - - const innerKey = key.inner(); - if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { - const encObj = await this.aesEncrypt(plainValue, innerKey); - const iv = Utils.fromBufferToB64(encObj.iv); - const data = Utils.fromBufferToB64(encObj.data); - const mac = Utils.fromBufferToB64(encObj.mac); - return new EncString(innerKey.type, data, iv, mac); - } else if (innerKey.type === EncryptionType.AesCbc256_B64) { - const encObj = await this.aesEncryptLegacy(plainValue, innerKey); - const iv = Utils.fromBufferToB64(encObj.iv); - const data = Utils.fromBufferToB64(encObj.data); - return new EncString(innerKey.type, data, iv); - } - } - - async encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer> { - if (key == null) { - throw new Error("No encryption key provided."); - } - - if (this.blockType0) { - if (key.inner().type === EncryptionType.AesCbc256_B64) { - throw new Error("Type 0 encryption is not supported."); - } - } - - const innerKey = key.inner(); - if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { - const encValue = await this.aesEncrypt(plainValue, innerKey); - const macLen = encValue.mac.length; - const encBytes = new Uint8Array( - 1 + encValue.iv.byteLength + macLen + encValue.data.byteLength, - ); - encBytes.set([innerKey.type]); - encBytes.set(new Uint8Array(encValue.iv), 1); - encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength); - encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen); - return new EncArrayBuffer(encBytes); - } else if (innerKey.type === EncryptionType.AesCbc256_B64) { - const encValue = await this.aesEncryptLegacy(plainValue, innerKey); - const encBytes = new Uint8Array(1 + encValue.iv.byteLength + encValue.data.byteLength); - encBytes.set([innerKey.type]); - encBytes.set(new Uint8Array(encValue.iv), 1); - encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength); - return new EncArrayBuffer(encBytes); - } - } + onServerConfigChange(newConfig: ServerConfig): void {} async decryptToUtf8( encString: EncString, key: SymmetricCryptoKey, - decryptContext: string = "no context", + _decryptContext: string = "no context", ): Promise<string> { - if (this.useSDKForDecryption) { - this.logService.debug("decrypting with SDK"); - if (encString == null || encString.encryptedString == null) { - throw new Error("encString is null or undefined"); - } - await SdkLoadService.Ready; - return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded()); - } - this.logService.debug("decrypting with javascript"); - - if (key == null) { - throw new Error("No key provided for decryption."); - } - - const innerKey = key.inner(); - if (encString.encryptionType !== innerKey.type) { - this.logDecryptError( - "Key encryption type does not match payload encryption type", - innerKey.type, - encString.encryptionType, - decryptContext, - ); - return null; - } - - if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { - const fastParams = this.cryptoFunctionService.aesDecryptFastParameters( - encString.data, - encString.iv, - encString.mac, - key, - ); - - const computedMac = await this.cryptoFunctionService.hmacFast( - fastParams.macData, - fastParams.macKey, - "sha256", - ); - const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); - if (!macsEqual) { - this.logMacFailed( - "decryptToUtf8 MAC comparison failed. Key or payload has changed.", - innerKey.type, - encString.encryptionType, - decryptContext, - ); - return null; - } - return await this.cryptoFunctionService.aesDecryptFast({ - mode: "cbc", - parameters: fastParams, - }); - } else if (innerKey.type === EncryptionType.AesCbc256_B64) { - const fastParams = this.cryptoFunctionService.aesDecryptFastParameters( - encString.data, - encString.iv, - undefined, - key, - ); - return await this.cryptoFunctionService.aesDecryptFast({ - mode: "cbc", - parameters: fastParams, - }); - } else { - throw new Error(`Unsupported encryption type`); - } + await SdkLoadService.Ready; + return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded()); } async decryptToBytes( encThing: Encrypted, key: SymmetricCryptoKey, - decryptContext: string = "no context", + _decryptContext: string = "no context", ): Promise<Uint8Array | null> { - if (this.useSDKForDecryption) { - this.logService.debug("[EncryptService] Decrypting bytes with SDK"); - if ( - encThing.encryptionType == null || - encThing.ivBytes == null || - encThing.dataBytes == null - ) { - throw new Error("Cannot decrypt, missing type, IV, or data bytes."); - } - const buffer = EncArrayBuffer.fromParts( - encThing.encryptionType, - encThing.ivBytes, - encThing.dataBytes, - encThing.macBytes, - ).buffer; - await SdkLoadService.Ready; - return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded()); - } - this.logService.debug("[EncryptService] Decrypting bytes with javascript"); - - if (key == null) { - throw new Error("No encryption key provided."); - } - - if (encThing == null) { - throw new Error("Nothing provided for decryption."); - } - - const inner = key.inner(); - if (encThing.encryptionType !== inner.type) { - this.logDecryptError( - "Encryption key type mismatch", - inner.type, - encThing.encryptionType, - decryptContext, - ); - return null; - } - - if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) { - if (encThing.macBytes == null) { - this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext); - return null; - } - - const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength); - macData.set(new Uint8Array(encThing.ivBytes), 0); - macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength); - const computedMac = await this.cryptoFunctionService.hmac( - macData, - inner.authenticationKey, - "sha256", - ); - const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac); - if (!macsMatch) { - this.logMacFailed( - "MAC comparison failed. Key or payload has changed.", - inner.type, - encThing.encryptionType, - decryptContext, - ); - return null; - } - - return await this.cryptoFunctionService.aesDecrypt( - encThing.dataBytes, - encThing.ivBytes, - inner.encryptionKey, - "cbc", - ); - } else if (inner.type === EncryptionType.AesCbc256_B64) { - return await this.cryptoFunctionService.aesDecrypt( - encThing.dataBytes, - encThing.ivBytes, - inner.encryptionKey, - "cbc", - ); + if (encThing.encryptionType == null || encThing.ivBytes == null || encThing.dataBytes == null) { + throw new Error("Cannot decrypt, missing type, IV, or data bytes."); } + const buffer = EncArrayBuffer.fromParts( + encThing.encryptionType, + encThing.ivBytes, + encThing.dataBytes, + encThing.macBytes, + ).buffer; + await SdkLoadService.Ready; + return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded()); } async encapsulateKeyUnsigned( @@ -398,14 +208,31 @@ export class EncryptServiceImplementation implements EncryptService { if (sharedKey == null) { throw new Error("No sharedKey provided for encapsulation"); } - return await this.rsaEncrypt(sharedKey.toEncoded(), encapsulationKey); + if (encapsulationKey == null) { + throw new Error("No encapsulationKey provided for encapsulation"); + } + await SdkLoadService.Ready; + return new EncString( + PureCrypto.encapsulate_key_unsigned(sharedKey.toEncoded(), encapsulationKey), + ); } async decapsulateKeyUnsigned( encryptedSharedKey: EncString, decapsulationKey: Uint8Array, ): Promise<SymmetricCryptoKey> { - const keyBytes = await this.rsaDecrypt(encryptedSharedKey, decapsulationKey); + if (encryptedSharedKey == null) { + throw new Error("No encryptedSharedKey provided for decapsulation"); + } + if (decapsulationKey == null) { + throw new Error("No decapsulationKey provided for decapsulation"); + } + + const keyBytes = PureCrypto.decapsulate_key_unsigned( + encryptedSharedKey.encryptedString, + decapsulationKey, + ); + await SdkLoadService.Ready; return new SymmetricCryptoKey(keyBytes); } @@ -428,51 +255,6 @@ export class EncryptServiceImplementation implements EncryptService { return results; } - private async aesEncrypt(data: Uint8Array, key: Aes256CbcHmacKey): Promise<EncryptedObject> { - const obj = new EncryptedObject(); - obj.iv = await this.cryptoFunctionService.randomBytes(16); - obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey); - - const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength); - macData.set(new Uint8Array(obj.iv), 0); - macData.set(new Uint8Array(obj.data), obj.iv.byteLength); - obj.mac = await this.cryptoFunctionService.hmac(macData, key.authenticationKey, "sha256"); - - return obj; - } - - /** - * @deprecated Removed once AesCbc256_B64 support is removed - */ - private async aesEncryptLegacy(data: Uint8Array, key: Aes256CbcKey): Promise<EncryptedObject> { - const obj = new EncryptedObject(); - obj.iv = await this.cryptoFunctionService.randomBytes(16); - obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey); - return obj; - } - - private logDecryptError( - msg: string, - keyEncType: EncryptionType, - dataEncType: EncryptionType, - decryptContext: string, - ) { - this.logService.error( - `[Encrypt service] ${msg} Key type ${encryptionTypeName(keyEncType)} Payload type ${encryptionTypeName(dataEncType)} Decrypt context: ${decryptContext}`, - ); - } - - private logMacFailed( - msg: string, - keyEncType: EncryptionType, - dataEncType: EncryptionType, - decryptContext: string, - ) { - if (this.logMacFailures) { - this.logDecryptError(msg, keyEncType, dataEncType, decryptContext); - } - } - async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString> { if (data == null) { throw new Error("No data provided for encryption."); diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 68021086bb1..4cc76d45f50 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -3,20 +3,14 @@ import { mockReset, mock } from "jest-mock-extended"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; -import { - Aes256CbcHmacKey, - SymmetricCryptoKey, -} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PureCrypto } from "@bitwarden/sdk-internal"; import { makeStaticByteArray } from "../../../../spec"; -import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum"; -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; @@ -26,24 +20,50 @@ describe("EncryptService", () => { let encryptService: EncryptServiceImplementation; + const testEncBuffer = EncArrayBuffer.fromParts( + EncryptionType.AesCbc256_HmacSha256_B64, + new Uint8Array(16), + new Uint8Array(32), + new Uint8Array(32), + ); + beforeEach(() => { mockReset(cryptoFunctionService); mockReset(logService); + jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(new Uint8Array(1)); + jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("decrypted_string"); + + jest.spyOn(PureCrypto, "symmetric_decrypt_filedata").mockReturnValue(new Uint8Array(1)); + jest.spyOn(PureCrypto, "symmetric_encrypt_filedata").mockReturnValue(testEncBuffer.buffer); + jest.spyOn(PureCrypto, "symmetric_decrypt_string").mockReturnValue("decrypted_string"); + jest.spyOn(PureCrypto, "symmetric_encrypt_string").mockReturnValue("encrypted_string"); + jest.spyOn(PureCrypto, "symmetric_decrypt_bytes").mockReturnValue(new Uint8Array(3)); + jest.spyOn(PureCrypto, "symmetric_encrypt_bytes").mockReturnValue("encrypted_bytes"); + + jest.spyOn(PureCrypto, "wrap_decapsulation_key").mockReturnValue("wrapped_decapsulation_key"); + jest.spyOn(PureCrypto, "wrap_encapsulation_key").mockReturnValue("wrapped_encapsulation_key"); + jest.spyOn(PureCrypto, "wrap_symmetric_key").mockReturnValue("wrapped_symmetric_key"); + jest.spyOn(PureCrypto, "unwrap_decapsulation_key").mockReturnValue(new Uint8Array(4)); + jest.spyOn(PureCrypto, "unwrap_encapsulation_key").mockReturnValue(new Uint8Array(5)); + jest.spyOn(PureCrypto, "unwrap_symmetric_key").mockReturnValue(new Uint8Array(64)); + + jest.spyOn(PureCrypto, "decapsulate_key_unsigned").mockReturnValue(new Uint8Array(64)); + jest.spyOn(PureCrypto, "encapsulate_key_unsigned").mockReturnValue("encapsulated_key_unsigned"); + (SdkLoadService as any).Ready = jest.fn().mockResolvedValue(true); + encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true); }); describe("wrapSymmetricKey", () => { - it("roundtrip encrypts and decrypts a symmetric key", async () => { - cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0)); - cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray); - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32)); - + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = await encryptService.wrapSymmetricKey(key, wrappingKey); - expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64); - expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0))); + await encryptService.wrapSymmetricKey(key, wrappingKey); + expect(PureCrypto.wrap_symmetric_key).toHaveBeenCalledWith( + key.toEncoded(), + wrappingKey.toEncoded(), + ); }); it("fails if key toBeWrapped is null", async () => { const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); @@ -57,33 +77,17 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); - it("fails if type 0 key is provided with flag turned on", async () => { - (encryptService as any).blockType0 = true; - const mock32Key = mock<SymmetricCryptoKey>(); - mock32Key.inner.mockReturnValue({ - type: 0, - encryptionKey: makeStaticByteArray(32), - }); - - await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - }); }); describe("wrapDecapsulationKey", () => { - it("roundtrip encrypts and decrypts a decapsulation key", async () => { - cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0)); - cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray); - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32)); - + it("is a proxy to PureCrypto", async () => { + const decapsulationKey = makeStaticByteArray(10); const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = await encryptService.wrapDecapsulationKey( - makeStaticByteArray(64), - wrappingKey, + await encryptService.wrapDecapsulationKey(decapsulationKey, wrappingKey); + expect(PureCrypto.wrap_decapsulation_key).toHaveBeenCalledWith( + decapsulationKey, + wrappingKey.toEncoded(), ); - expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64); - expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0))); }); it("fails if decapsulation key is null", async () => { const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); @@ -97,33 +101,17 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); - it("throws if type 0 key is provided with flag turned on", async () => { - (encryptService as any).blockType0 = true; - const mock32Key = mock<SymmetricCryptoKey>(); - mock32Key.inner.mockReturnValue({ - type: 0, - encryptionKey: makeStaticByteArray(32), - }); - - await expect( - encryptService.wrapDecapsulationKey(new Uint8Array(200), mock32Key), - ).rejects.toThrow("Type 0 encryption is not supported."); - }); }); describe("wrapEncapsulationKey", () => { - it("roundtrip encrypts and decrypts an encapsulationKey key", async () => { - cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0)); - cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray); - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32)); - + it("is a proxy to PureCrypto", async () => { + const encapsulationKey = makeStaticByteArray(10); const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = await encryptService.wrapEncapsulationKey( - makeStaticByteArray(64), - wrappingKey, + await encryptService.wrapEncapsulationKey(encapsulationKey, wrappingKey); + expect(PureCrypto.wrap_encapsulation_key).toHaveBeenCalledWith( + encapsulationKey, + wrappingKey.toEncoded(), ); - expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64); - expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0))); }); it("fails if encapsulation key is null", async () => { const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64)); @@ -137,535 +125,152 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); - it("throws if type 0 key is provided with flag turned on", async () => { - (encryptService as any).blockType0 = true; - const mock32Key = mock<SymmetricCryptoKey>(); - mock32Key.inner.mockReturnValue({ - type: 0, - encryptionKey: makeStaticByteArray(32), - }); - - await expect( - encryptService.wrapEncapsulationKey(new Uint8Array(200), mock32Key), - ).rejects.toThrow("Type 0 encryption is not supported."); - }); - }); - - describe("onServerConfigChange", () => { - const newConfig = mock<ServerConfig>(); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("updates internal flag with default value when not present in config", () => { - encryptService.onServerConfigChange(newConfig); - - expect((encryptService as any).blockType0).toBe( - DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0], - ); - }); - - test.each([true, false])("updates internal flag with value in config", (expectedValue) => { - newConfig.featureStates = { [FeatureFlag.PM17987_BlockType0]: expectedValue }; - - encryptService.onServerConfigChange(newConfig); - - expect((encryptService as any).blockType0).toBe(expectedValue); - }); - }); - - describe("encrypt", () => { - it("throws if no key is provided", () => { - return expect(encryptService.encrypt(null, null)).rejects.toThrow( - "No encryption key provided.", - ); - }); - - it("throws if type 0 key is provided with flag turned on", async () => { - (encryptService as any).blockType0 = true; - const key = new SymmetricCryptoKey(makeStaticByteArray(32)); - const mock32Key = mock<SymmetricCryptoKey>(); - mock32Key.inner.mockReturnValue({ - type: 0, - encryptionKey: makeStaticByteArray(32), - }); - - await expect(encryptService.encrypt(null!, key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - await expect(encryptService.encrypt(null!, mock32Key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - - const plainValue = "data"; - await expect(encryptService.encrypt(plainValue, key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - await expect(encryptService.encrypt(plainValue, mock32Key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - }); - - it("returns null if no data is provided with valid key", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const actual = await encryptService.encrypt(null, key); - expect(actual).toBeNull(); - }); - - it("creates an EncString for Aes256Cbc", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32)); - const plainValue = "data"; - cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(4, 100)); - cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray); - const result = await encryptService.encrypt(plainValue, key); - expect(cryptoFunctionService.aesEncrypt).toHaveBeenCalledWith( - Utils.fromByteStringToArray(plainValue), - makeStaticByteArray(16), - makeStaticByteArray(32), - ); - expect(cryptoFunctionService.hmac).not.toHaveBeenCalled(); - - expect(Utils.fromB64ToArray(result.data).length).toEqual(4); - expect(Utils.fromB64ToArray(result.iv).length).toEqual(16); - }); - - it("creates an EncString for Aes256Cbc_HmacSha256_B64", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const plainValue = "data"; - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32)); - cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(4, 100)); - cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray); - const result = await encryptService.encrypt(plainValue, key); - expect(cryptoFunctionService.aesEncrypt).toHaveBeenCalledWith( - Utils.fromByteStringToArray(plainValue), - makeStaticByteArray(16), - makeStaticByteArray(32), - ); - - const macData = new Uint8Array(16 + 4); - macData.set(makeStaticByteArray(16)); - macData.set(makeStaticByteArray(4, 100), 16); - expect(cryptoFunctionService.hmac).toHaveBeenCalledWith( - macData, - makeStaticByteArray(32, 32), - "sha256", - ); - - expect(Utils.fromB64ToArray(result.data).length).toEqual(4); - expect(Utils.fromB64ToArray(result.iv).length).toEqual(16); - expect(Utils.fromB64ToArray(result.mac).length).toEqual(32); - }); - }); - - describe("encryptToBytes", () => { - const plainValue = makeStaticByteArray(16, 1); - - it("throws if no key is provided", () => { - return expect(encryptService.encryptToBytes(plainValue, null)).rejects.toThrow( - "No encryption key", - ); - }); - - it("throws if type 0 key provided with flag turned on", async () => { - (encryptService as any).blockType0 = true; - const key = new SymmetricCryptoKey(makeStaticByteArray(32)); - const mock32Key = mock<SymmetricCryptoKey>(); - mock32Key.inner.mockReturnValue({ - type: 0, - encryptionKey: makeStaticByteArray(32), - }); - - await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - - await expect(encryptService.encryptToBytes(plainValue, mock32Key)).rejects.toThrow( - "Type 0 encryption is not supported.", - ); - }); - - it("encrypts data with provided Aes256Cbc key and returns correct encbuffer", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0)); - const iv = makeStaticByteArray(16, 80); - const cipherText = makeStaticByteArray(20, 150); - cryptoFunctionService.randomBytes.mockResolvedValue(iv as CsprngArray); - cryptoFunctionService.aesEncrypt.mockResolvedValue(cipherText); - - const actual = await encryptService.encryptToBytes(plainValue, key); - const expectedBytes = new Uint8Array(1 + iv.byteLength + cipherText.byteLength); - expectedBytes.set([EncryptionType.AesCbc256_B64]); - expectedBytes.set(iv, 1); - expectedBytes.set(cipherText, 1 + iv.byteLength); - - expect(actual.buffer).toEqualBuffer(expectedBytes); - }); - - it("encrypts data with provided Aes256Cbc_HmacSha256 key and returns correct encbuffer", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const iv = makeStaticByteArray(16, 80); - const mac = makeStaticByteArray(32, 100); - const cipherText = makeStaticByteArray(20, 150); - cryptoFunctionService.randomBytes.mockResolvedValue(iv as CsprngArray); - cryptoFunctionService.aesEncrypt.mockResolvedValue(cipherText); - cryptoFunctionService.hmac.mockResolvedValue(mac); - - const actual = await encryptService.encryptToBytes(plainValue, key); - const expectedBytes = new Uint8Array( - 1 + iv.byteLength + mac.byteLength + cipherText.byteLength, - ); - expectedBytes.set([EncryptionType.AesCbc256_HmacSha256_B64]); - expectedBytes.set(iv, 1); - expectedBytes.set(mac, 1 + iv.byteLength); - expectedBytes.set(cipherText, 1 + iv.byteLength + mac.byteLength); - - expect(actual.buffer).toEqualBuffer(expectedBytes); - }); - }); - - describe("decryptToBytes", () => { - const encType = EncryptionType.AesCbc256_HmacSha256_B64; - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100)); - const computedMac = new Uint8Array(1); - const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, encType)); - - beforeEach(() => { - cryptoFunctionService.hmac.mockResolvedValue(computedMac); - }); - - it("throws if no key is provided", () => { - return expect(encryptService.decryptToBytes(encBuffer, null)).rejects.toThrow( - "No encryption key", - ); - }); - - it("throws if no encrypted value is provided", () => { - return expect(encryptService.decryptToBytes(null, key)).rejects.toThrow( - "Nothing provided for decryption", - ); - }); - - it("calls PureCrypto when useSDKForDecryption is true", async () => { - (encryptService as any).useSDKForDecryption = true; - const decryptedBytes = makeStaticByteArray(10, 200); - Object.defineProperty(SdkLoadService, "Ready", { - value: Promise.resolve(), - configurable: true, - }); - jest.spyOn(PureCrypto, "symmetric_decrypt_array_buffer").mockReturnValue(decryptedBytes); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - - expect(PureCrypto.symmetric_decrypt_array_buffer).toHaveBeenCalledWith( - encBuffer.buffer, - key.toEncoded(), - ); - expect(actual).toEqualBuffer(decryptedBytes); - }); - - it("decrypts data with provided key for Aes256CbcHmac", async () => { - const decryptedBytes = makeStaticByteArray(10, 200); - - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1)); - cryptoFunctionService.compare.mockResolvedValue(true); - cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - - expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( - expect.toEqualBuffer(encBuffer.dataBytes), - expect.toEqualBuffer(encBuffer.ivBytes), - expect.toEqualBuffer(key.inner().encryptionKey), - "cbc", - ); - - expect(actual).toEqualBuffer(decryptedBytes); - }); - - it("decrypts data with provided key for Aes256Cbc", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0)); - const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, EncryptionType.AesCbc256_B64)); - const decryptedBytes = makeStaticByteArray(10, 200); - - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1)); - cryptoFunctionService.compare.mockResolvedValue(true); - cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - - expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( - expect.toEqualBuffer(encBuffer.dataBytes), - expect.toEqualBuffer(encBuffer.ivBytes), - expect.toEqualBuffer(key.inner().encryptionKey), - "cbc", - ); - - expect(actual).toEqualBuffer(decryptedBytes); - }); - - it("compares macs using CryptoFunctionService", async () => { - const expectedMacData = new Uint8Array( - encBuffer.ivBytes.byteLength + encBuffer.dataBytes.byteLength, - ); - expectedMacData.set(new Uint8Array(encBuffer.ivBytes)); - expectedMacData.set(new Uint8Array(encBuffer.dataBytes), encBuffer.ivBytes.byteLength); - - await encryptService.decryptToBytes(encBuffer, key); - - expect(cryptoFunctionService.hmac).toBeCalledWith( - expect.toEqualBuffer(expectedMacData), - (key.inner() as Aes256CbcHmacKey).authenticationKey, - "sha256", - ); - - expect(cryptoFunctionService.compare).toBeCalledWith( - expect.toEqualBuffer(encBuffer.macBytes), - expect.toEqualBuffer(computedMac), - ); - }); - - it("returns null if macs don't match", async () => { - cryptoFunctionService.compare.mockResolvedValue(false); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - expect(cryptoFunctionService.compare).toHaveBeenCalled(); - expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); - expect(actual).toBeNull(); - }); - - it("returns null if mac could not be calculated", async () => { - cryptoFunctionService.hmac.mockResolvedValue(null); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - expect(cryptoFunctionService.hmac).toHaveBeenCalled(); - expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); - expect(actual).toBeNull(); - }); - - it("returns null if key is Aes256Cbc but encbuffer is Aes256Cbc_HmacSha256", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0)); - cryptoFunctionService.compare.mockResolvedValue(true); - - const actual = await encryptService.decryptToBytes(encBuffer, key); - - expect(actual).toBeNull(); - expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); - }); - - it("returns null if key is Aes256Cbc_HmacSha256 but encbuffer is Aes256Cbc", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - cryptoFunctionService.compare.mockResolvedValue(true); - const buffer = new EncArrayBuffer(makeStaticByteArray(200, EncryptionType.AesCbc256_B64)); - const actual = await encryptService.decryptToBytes(buffer, key); - - expect(actual).toBeNull(); - expect(cryptoFunctionService.aesDecrypt).not.toHaveBeenCalled(); - }); - }); - - describe("decryptToUtf8", () => { - it("throws if no key is provided", () => { - return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow( - "No key provided for decryption.", - ); - }); - - it("calls PureCrypto when useSDKForDecryption is true", async () => { - (encryptService as any).useSDKForDecryption = true; - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); - Object.defineProperty(SdkLoadService, "Ready", { - value: Promise.resolve(), - configurable: true, - }); - jest.spyOn(PureCrypto, "symmetric_decrypt").mockReturnValue("data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - - expect(actual).toEqual("data"); - expect(PureCrypto.symmetric_decrypt).toHaveBeenCalledWith( - encString.encryptedString, - key.toEncoded(), - ); - }); - - it("decrypts data with provided key for AesCbc256_HmacSha256", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); - cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({ - macData: makeStaticByteArray(32, 0), - macKey: makeStaticByteArray(32, 0), - mac: makeStaticByteArray(32, 0), - } as any); - cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0)); - cryptoFunctionService.compareFast.mockResolvedValue(true); - cryptoFunctionService.aesDecryptFast.mockResolvedValue("data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toEqual("data"); - expect(cryptoFunctionService.compareFast).toHaveBeenCalledWith( - makeStaticByteArray(32, 0), - makeStaticByteArray(32, 0), - ); - }); - - it("decrypts data with provided key for AesCbc256", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({ - macData: makeStaticByteArray(32, 0), - macKey: makeStaticByteArray(32, 0), - mac: makeStaticByteArray(32, 0), - } as any); - cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0)); - cryptoFunctionService.compareFast.mockResolvedValue(true); - cryptoFunctionService.aesDecryptFast.mockResolvedValue("data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toEqual("data"); - expect(cryptoFunctionService.compareFast).not.toHaveBeenCalled(); - }); - - it("returns null if key is AesCbc256_HMAC but encstring is AesCbc256", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toBeNull(); - expect(logService.error).toHaveBeenCalled(); - }); - - it("returns null if key is AesCbc256 but encstring is AesCbc256_HMAC", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0)); - const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toBeNull(); - expect(logService.error).toHaveBeenCalled(); - }); - - it("returns null if macs don't match", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac"); - cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({ - macData: makeStaticByteArray(32, 0), - macKey: makeStaticByteArray(32, 0), - mac: makeStaticByteArray(32, 0), - } as any); - cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0)); - cryptoFunctionService.compareFast.mockResolvedValue(false); - cryptoFunctionService.aesDecryptFast.mockResolvedValue("data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toBeNull(); - }); - }); - - describe("decryptToUtf8", () => { - it("throws if no key is provided", () => { - return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow( - "No key provided for decryption.", - ); - }); - it("returns null if key is mac key but encstring has no mac", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - - const actual = await encryptService.decryptToUtf8(encString, key); - expect(actual).toBeNull(); - expect(logService.error).toHaveBeenCalled(); - }); }); describe("encryptString", () => { - it("is a proxy to encrypt", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const plainValue = "data"; - encryptService.encrypt = jest.fn(); - await encryptService.encryptString(plainValue, key); - expect(encryptService.encrypt).toHaveBeenCalledWith(plainValue, key); + const result = await encryptService.encryptString(plainValue, key); + expect(result).toEqual(new EncString("encrypted_string")); + expect(PureCrypto.symmetric_encrypt_string).toHaveBeenCalledWith(plainValue, key.toEncoded()); }); }); describe("encryptBytes", () => { - it("is a proxy to encrypt", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const plainValue = makeStaticByteArray(16, 1); - encryptService.encrypt = jest.fn(); - await encryptService.encryptBytes(plainValue, key); - expect(encryptService.encrypt).toHaveBeenCalledWith(plainValue, key); + const result = await encryptService.encryptBytes(plainValue, key); + expect(result).toEqual(new EncString("encrypted_bytes")); + expect(PureCrypto.symmetric_encrypt_bytes).toHaveBeenCalledWith(plainValue, key.toEncoded()); }); }); describe("encryptFileData", () => { - it("is a proxy to encryptToBytes", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const plainValue = makeStaticByteArray(16, 1); - encryptService.encryptToBytes = jest.fn(); - await encryptService.encryptFileData(plainValue, key); - expect(encryptService.encryptToBytes).toHaveBeenCalledWith(plainValue, key); + const result = await encryptService.encryptFileData(plainValue, key); + expect(result).toEqual(testEncBuffer); + expect(PureCrypto.symmetric_encrypt_filedata).toHaveBeenCalledWith( + plainValue, + key.toEncoded(), + ); }); }); describe("decryptString", () => { - it("is a proxy to decryptToUtf8", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - encryptService.decryptToUtf8 = jest.fn(); - await encryptService.decryptString(encString, key); - expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key); + const encString = new EncString("encrypted_string"); + const result = await encryptService.decryptString(encString, key); + expect(result).toEqual("decrypted_string"); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); }); }); describe("decryptBytes", () => { - it("is a proxy to decryptToBytes", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - encryptService.decryptToBytes = jest.fn(); - await encryptService.decryptBytes(encString, key); - expect(encryptService.decryptToBytes).toHaveBeenCalledWith(encString, key); + const encString = new EncString("encrypted_bytes"); + const result = await encryptService.decryptBytes(encString, key); + expect(result).toEqual(new Uint8Array(3)); + expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); }); }); describe("decryptFileData", () => { - it("is a proxy to decrypt", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncArrayBuffer(makeStaticByteArray(60, EncryptionType.AesCbc256_B64)); - encryptService.decryptToBytes = jest.fn(); - await encryptService.decryptFileData(encString, key); - expect(encryptService.decryptToBytes).toHaveBeenCalledWith(encString, key); + const encString = new EncArrayBuffer(testEncBuffer.buffer); + const result = await encryptService.decryptFileData(encString, key); + expect(result).toEqual(new Uint8Array(1)); + expect(PureCrypto.symmetric_decrypt_filedata).toHaveBeenCalledWith( + encString.buffer, + key.toEncoded(), + ); }); }); describe("unwrapDecapsulationKey", () => { - it("is a proxy to decryptBytes", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - encryptService.decryptBytes = jest.fn(); - await encryptService.unwrapDecapsulationKey(encString, key); - expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key); + const encString = new EncString("wrapped_decapsulation_key"); + const result = await encryptService.unwrapDecapsulationKey(encString, key); + expect(result).toEqual(new Uint8Array(4)); + expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); + }); + it("throws if wrappedDecapsulationKey is null", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); + return expect(encryptService.unwrapDecapsulationKey(null, key)).rejects.toThrow( + "No wrappedDecapsulationKey provided for unwrapping.", + ); + }); + it("throws if wrappingKey is null", () => { + const encString = new EncString("wrapped_decapsulation_key"); + return expect(encryptService.unwrapDecapsulationKey(encString, null)).rejects.toThrow( + "No wrappingKey provided for unwrapping.", + ); }); }); describe("unwrapEncapsulationKey", () => { - it("is a proxy to decryptBytes", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - encryptService.decryptBytes = jest.fn(); - await encryptService.unwrapEncapsulationKey(encString, key); - expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key); + const encString = new EncString("wrapped_encapsulation_key"); + const result = await encryptService.unwrapEncapsulationKey(encString, key); + expect(result).toEqual(new Uint8Array(5)); + expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); + }); + it("throws if wrappedEncapsulationKey is null", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); + return expect(encryptService.unwrapEncapsulationKey(null, key)).rejects.toThrow( + "No wrappedEncapsulationKey provided for unwrapping.", + ); + }); + it("throws if wrappingKey is null", () => { + const encString = new EncString("wrapped_encapsulation_key"); + return expect(encryptService.unwrapEncapsulationKey(encString, null)).rejects.toThrow( + "No wrappingKey provided for unwrapping.", + ); }); }); describe("unwrapSymmetricKey", () => { - it("is a proxy to decryptBytes", async () => { + it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); - const jestFn = jest.fn(); - jestFn.mockResolvedValue(new Uint8Array(64)); - encryptService.decryptBytes = jestFn; - await encryptService.unwrapSymmetricKey(encString, key); - expect(encryptService.decryptBytes).toHaveBeenCalledWith(encString, key); + const encString = new EncString("wrapped_symmetric_key"); + const result = await encryptService.unwrapSymmetricKey(encString, key); + expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); + expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith( + encString.encryptedString, + key.toEncoded(), + ); + }); + it("throws if keyToBeUnwrapped is null", () => { + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); + return expect(encryptService.unwrapSymmetricKey(null, key)).rejects.toThrow( + "No keyToBeUnwrapped provided for unwrapping.", + ); + }); + it("throws if wrappingKey is null", () => { + const encString = new EncString("wrapped_symmetric_key"); + return expect(encryptService.unwrapSymmetricKey(encString, null)).rejects.toThrow( + "No wrappingKey provided for unwrapping.", + ); }); }); @@ -690,23 +295,13 @@ describe("EncryptService", () => { it("throws if no public key is provided", () => { return expect(encryptService.encapsulateKeyUnsigned(testKey, null)).rejects.toThrow( - "No public key", + "No encapsulationKey provided for encapsulation", ); }); it("encrypts data with provided key", async () => { - cryptoFunctionService.rsaEncrypt.mockResolvedValue(encryptedData); - const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey); - - expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith( - expect.toEqualBuffer(testKey.toEncoded()), - expect.toEqualBuffer(publicKey), - "sha1", - ); - - expect(actual).toEqual(encString); - expect(actual.dataBytes).toEqualBuffer(encryptedData); + expect(actual).toEqual(new EncString("encapsulated_key_unsigned")); }); it("throws if no data was provided", () => { @@ -719,39 +314,19 @@ describe("EncryptService", () => { describe("decapsulateKeyUnsigned", () => { it("throws if no data is provided", () => { return expect(encryptService.decapsulateKeyUnsigned(null, privateKey)).rejects.toThrow( - "No data", + "No encryptedSharedKey provided for decapsulation", ); }); it("throws if no private key is provided", () => { return expect(encryptService.decapsulateKeyUnsigned(encString, null)).rejects.toThrow( - "No private key", + "No decapsulationKey provided for decapsulation", ); }); - it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])( - "throws if encryption type is %s", - async (encType) => { - encString.encryptionType = encType; - - await expect( - encryptService.decapsulateKeyUnsigned(encString, privateKey), - ).rejects.toThrow("Invalid encryption type"); - }, - ); - it("decrypts data with provided key", async () => { - cryptoFunctionService.rsaDecrypt.mockResolvedValue(data); - const actual = await encryptService.decapsulateKeyUnsigned(makeEncString(data), privateKey); - - expect(cryptoFunctionService.rsaDecrypt).toBeCalledWith( - expect.toEqualBuffer(data), - expect.toEqualBuffer(privateKey), - "sha1", - ); - - expect(actual.toEncoded()).toEqualBuffer(data); + expect(actual.toEncoded()).toEqualBuffer(new Uint8Array(64)); }); }); }); diff --git a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.spec.ts deleted file mode 100644 index c016724e652..00000000000 --- a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { BulkEncryptService } from "../abstractions/bulk-encrypt.service"; -import { EncryptService } from "../abstractions/encrypt.service"; - -import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service"; - -describe("FallbackBulkEncryptService", () => { - const encryptService = mock<EncryptService>(); - const featureFlagEncryptService = mock<BulkEncryptService>(); - const serverConfig = mock<ServerConfig>(); - - let sut: FallbackBulkEncryptService; - - beforeEach(() => { - sut = new FallbackBulkEncryptService(encryptService); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("decryptItems", () => { - const key = mock<SymmetricCryptoKey>(); - const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[]; - const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[]; - - it("calls decryptItems on featureFlagEncryptService when it is set", async () => { - featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems); - await sut.setFeatureFlagEncryptService(featureFlagEncryptService); - - const result = await sut.decryptItems(mockItems, key); - - expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key); - expect(encryptService.decryptItems).not.toHaveBeenCalled(); - expect(result).toEqual(mockDecryptedItems); - }); - - it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => { - encryptService.decryptItems.mockResolvedValue(mockDecryptedItems); - - const result = await sut.decryptItems(mockItems, key); - - expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key); - expect(result).toEqual(mockDecryptedItems); - }); - }); - - describe("setFeatureFlagEncryptService", () => { - it("sets the featureFlagEncryptService property", async () => { - await sut.setFeatureFlagEncryptService(featureFlagEncryptService); - - expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService); - }); - - it("does not call onServerConfigChange when currentServerConfig is undefined", async () => { - await sut.setFeatureFlagEncryptService(featureFlagEncryptService); - - expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled(); - expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService); - }); - - it("calls onServerConfigChange with currentServerConfig when it is defined", async () => { - sut.onServerConfigChange(serverConfig); - - await sut.setFeatureFlagEncryptService(featureFlagEncryptService); - - expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig); - expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService); - }); - }); - - describe("onServerConfigChange", () => { - it("updates internal currentServerConfig to new config", async () => { - sut.onServerConfigChange(serverConfig); - - expect((sut as any).currentServerConfig).toBe(serverConfig); - }); - - it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => { - await sut.setFeatureFlagEncryptService(featureFlagEncryptService); - - sut.onServerConfigChange(serverConfig); - - expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig); - expect(encryptService.onServerConfigChange).not.toHaveBeenCalled(); - }); - - it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => { - sut.onServerConfigChange(serverConfig); - - expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig); - }); - }); -}); diff --git a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts index 7eefa896e6a..0a05bff4422 100644 --- a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts +++ b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts @@ -9,7 +9,7 @@ import { ServerConfig } from "../../../platform/abstractions/config/server-confi import { EncryptService } from "../abstractions/encrypt.service"; /** - * @deprecated For the feature flag from PM-4154, remove once feature is rolled out + * @deprecated Will be deleted in an immediate subsequent PR */ export class FallbackBulkEncryptService implements BulkEncryptService { private featureFlagEncryptService: BulkEncryptService; @@ -25,22 +25,10 @@ export class FallbackBulkEncryptService implements BulkEncryptService { items: Decryptable<T>[], key: SymmetricCryptoKey, ): Promise<T[]> { - if (this.featureFlagEncryptService != null) { - return await this.featureFlagEncryptService.decryptItems(items, key); - } else { - return await this.encryptService.decryptItems(items, key); - } + return await this.encryptService.decryptItems(items, key); } - async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) { - if (this.currentServerConfig !== undefined) { - featureFlagEncryptService.onServerConfigChange(this.currentServerConfig); - } - this.featureFlagEncryptService = featureFlagEncryptService; - } + async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {} - onServerConfigChange(newConfig: ServerConfig): void { - this.currentServerConfig = newConfig; - (this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig); - } + onServerConfigChange(newConfig: ServerConfig): void {} } diff --git a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.spec.ts b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.spec.ts deleted file mode 100644 index bb966c32507..00000000000 --- a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { mock } from "jest-mock-extended"; -import * as rxjs from "rxjs"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { LogService } from "../../../platform/abstractions/log.service"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { CryptoFunctionService } from "../abstractions/crypto-function.service"; -import { buildSetConfigMessage } from "../types/worker-command.type"; - -import { EncryptServiceImplementation } from "./encrypt.service.implementation"; -import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation"; - -describe("MultithreadEncryptServiceImplementation", () => { - const cryptoFunctionService = mock<CryptoFunctionService>(); - const logService = mock<LogService>(); - const serverConfig = mock<ServerConfig>(); - - let sut: MultithreadEncryptServiceImplementation; - - beforeEach(() => { - sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("decryptItems", () => { - const key = mock<SymmetricCryptoKey>(); - const mockWorker = mock<Worker>(); - - beforeEach(() => { - // Mock creating a worker. - global.Worker = jest.fn().mockImplementation(() => mockWorker); - global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL; - global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url"); - global.URL.revokeObjectURL = jest.fn(); - global.URL.canParse = jest.fn().mockReturnValue(true); - - // Mock the workers returned response. - const mockMessageEvent = { - id: "mock-guid", - data: ["decrypted1", "decrypted2"], - }; - const mockMessageEvent$ = rxjs.from([mockMessageEvent]); - jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$); - }); - - it("returns empty array if items is null", async () => { - const items = null as unknown as Decryptable<any>[]; - const result = await sut.decryptItems(items, key); - expect(result).toEqual([]); - }); - - it("returns empty array if items is empty", async () => { - const result = await sut.decryptItems([], key); - expect(result).toEqual([]); - }); - - it("creates worker if none exists", async () => { - // Make sure currentServerConfig is undefined so a SetConfigMessage is not sent. - (sut as any).currentServerConfig = undefined; - - await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key); - - expect(global.Worker).toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(1); - expect(mockWorker.postMessage).not.toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - - it("sends a SetConfigMessage to the new worker when there is a current server config", async () => { - // Populate currentServerConfig so a SetConfigMessage is sent. - (sut as any).currentServerConfig = serverConfig; - - await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key); - - expect(global.Worker).toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(2); - expect(mockWorker.postMessage).toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - - it("does not create worker if one exists", async () => { - (sut as any).currentServerConfig = serverConfig; - (sut as any).worker = mockWorker; - - await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key); - - expect(global.Worker).not.toHaveBeenCalled(); - expect(mockWorker.postMessage).toHaveBeenCalledTimes(1); - expect(mockWorker.postMessage).not.toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - }); - - describe("onServerConfigChange", () => { - it("updates internal currentServerConfig to new config and calls super", () => { - const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange"); - - sut.onServerConfigChange(serverConfig); - - expect(superSpy).toHaveBeenCalledWith(serverConfig); - expect((sut as any).currentServerConfig).toBe(serverConfig); - }); - - it("sends config update to worker if worker exists", () => { - const mockWorker = mock<Worker>(); - (sut as any).worker = mockWorker; - - sut.onServerConfigChange(serverConfig); - - expect(mockWorker.postMessage).toHaveBeenCalledTimes(1); - expect(mockWorker.postMessage).toHaveBeenCalledWith( - buildSetConfigMessage({ newConfig: serverConfig }), - ); - }); - }); -}); diff --git a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts index a3a663f72a9..ab65074af3b 100644 --- a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts @@ -1,31 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { defaultIfEmpty, filter, firstValueFrom, fromEvent, map, Subject, takeUntil } from "rxjs"; -import { Jsonify } from "type-fest"; - import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer"; import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; -// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive -const workerTTL = 3 * 60000; // 3 minutes - /** - * @deprecated Replaced by BulkEncryptionService (PM-4154) + * @deprecated Will be deleted in an immediate subsequent PR */ export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation { - private worker: Worker; - private timeout: any; - private currentServerConfig: ServerConfig | undefined = undefined; - - private clear$ = new Subject<void>(); + protected useSDKForDecryption: boolean = true; /** * Sends items to a web worker to decrypt them. @@ -35,84 +20,8 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple items: Decryptable<T>[], key: SymmetricCryptoKey, ): Promise<T[]> { - if (items == null || items.length < 1) { - return []; - } - - if (this.useSDKForDecryption) { - return await super.decryptItems(items, key); - } - - this.logService.info("Starting decryption using multithreading"); - - if (this.worker == null) { - this.worker = new Worker( - new URL( - /* webpackChunkName: 'encrypt-worker' */ - "@bitwarden/common/key-management/crypto/services/encrypt.worker.ts", - import.meta.url, - ), - ); - if (this.currentServerConfig !== undefined) { - this.updateWorkerServerConfig(this.currentServerConfig); - } - } - - this.restartTimeout(); - - const id = Utils.newGuid(); - const request = buildDecryptMessage({ - id, - items: items, - key: key, - }); - - this.worker.postMessage(request); - - return await firstValueFrom( - fromEvent(this.worker, "message").pipe( - filter((response: MessageEvent) => response.data?.id === id), - map((response) => JSON.parse(response.data.items)), - map((items) => - items.map((jsonItem: Jsonify<T>) => { - const initializer = getClassInitializer<T>(jsonItem.initializerKey); - return initializer(jsonItem); - }), - ), - takeUntil(this.clear$), - defaultIfEmpty([]), - ), - ); + return await super.decryptItems(items, key); } - override onServerConfigChange(newConfig: ServerConfig): void { - this.currentServerConfig = newConfig; - super.onServerConfigChange(newConfig); - this.updateWorkerServerConfig(newConfig); - } - - private updateWorkerServerConfig(newConfig: ServerConfig) { - if (this.worker != null) { - const request = buildSetConfigMessage({ newConfig }); - this.worker.postMessage(request); - } - } - - private clear() { - this.clear$.next(); - this.worker?.terminate(); - this.worker = null; - this.clearTimeout(); - } - - private restartTimeout() { - this.clearTimeout(); - this.timeout = setTimeout(() => this.clear(), workerTTL); - } - - private clearTimeout() { - if (this.timeout != null) { - clearTimeout(this.timeout); - } - } + override onServerConfigChange(newConfig: ServerConfig): void {} } diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts index 0037ba3391d..ae968fc6844 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts @@ -233,48 +233,6 @@ describe("WebCrypto Function Service", () => { }); }); - describe("aesEncrypt CBC mode", () => { - it("should successfully encrypt data", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const iv = makeStaticByteArray(16); - const key = makeStaticByteArray(32); - const data = Utils.fromUtf8ToArray("EncryptMe!"); - const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key); - expect(Utils.fromBufferToB64(encValue)).toBe("ByUF8vhyX4ddU9gcooznwA=="); - }); - - it("should successfully encrypt and then decrypt data fast", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const iv = makeStaticByteArray(16); - const key = makeStaticByteArray(32); - const value = "EncryptMe!"; - const data = Utils.fromUtf8ToArray(value); - const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key); - const encData = Utils.fromBufferToB64(encValue); - const b64Iv = Utils.fromBufferToB64(iv); - const symKey = new SymmetricCryptoKey(key); - const parameters = cryptoFunctionService.aesDecryptFastParameters( - encData, - b64Iv, - null, - symKey, - ); - const decValue = await cryptoFunctionService.aesDecryptFast({ mode: "cbc", parameters }); - expect(decValue).toBe(value); - }); - - it("should successfully encrypt and then decrypt data", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const iv = makeStaticByteArray(16); - const key = makeStaticByteArray(32); - const value = "EncryptMe!"; - const data = Utils.fromUtf8ToArray(value); - const encValue = new Uint8Array(await cryptoFunctionService.aesEncrypt(data, iv, key)); - const decValue = await cryptoFunctionService.aesDecrypt(encValue, iv, key, "cbc"); - expect(Utils.fromBufferToUtf8(decValue)).toBe(value); - }); - }); - describe("aesDecryptFast CBC mode", () => { it("should successfully decrypt data", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 32cbd58dde2..80a2a31dea6 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -204,14 +204,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return equals; } - async aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise<Uint8Array> { - const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [ - "encrypt", - ]); - const buffer = await this.subtle.encrypt({ name: "AES-CBC", iv: iv }, impKey, data); - return new Uint8Array(buffer); - } - aesDecryptFastParameters( data: string, iv: string, diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 97470b23697..57463b3b42b 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -89,10 +89,13 @@ export class SendService implements InternalSendServiceAbstraction { } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); + // FIXME: model.name can be null. encryptString should not be called with null values. send.name = await this.encryptService.encryptString(model.name, model.cryptoKey); + // FIXME: model.notes can be null. encryptString should not be called with null values. send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey); if (send.type === SendType.Text) { send.text = new SendText(); + // FIXME: model.text.text can be null. encryptString should not be called with null values. send.text.text = await this.encryptService.encryptString(model.text.text, model.cryptoKey); send.text.hidden = model.text.hidden; } else if (send.type === SendType.File) { diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 45aba21c0cf..fca080252f6 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -3,10 +3,13 @@ import { NEVER, Observable, combineLatest, + distinctUntilChanged, + filter, firstValueFrom, forkJoin, map, of, + shareReplay, switchMap, } from "rxjs"; @@ -83,6 +86,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { ) { this.activeUserOrgKeys$ = this.stateProvider.activeUserId$.pipe( switchMap((userId) => (userId != null ? this.orgKeys$(userId) : NEVER)), + filter((orgKeys) => orgKeys != null), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: false }), ) as Observable<Record<OrganizationId, OrgKey>>; } @@ -402,12 +408,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { } async getOrgKey(orgId: OrganizationId): Promise<OrgKey | null> { - const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); - if (activeUserId == null) { - throw new Error("A user must be active to retrieve an org key"); - } - const orgKeys = await firstValueFrom(this.orgKeys$(activeUserId)); - return orgKeys?.[orgId] ?? null; + return await firstValueFrom( + this.activeUserOrgKeys$.pipe(map((orgKeys) => orgKeys[orgId] ?? null)), + ); } async makeDataEncKey<T extends OrgKey | UserKey>( From 167fa9a7ab1456f8ea4e2933057dd370c42b83cc Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:35:31 +0200 Subject: [PATCH 25/54] [PM-18054] Chrome extension biometric unlock not functioning correctly with Windows Hello. (#14953) * Chrome extension biometric unlock not functioning correctly with Windows Hello. When unlocking via Windows Hello prompt, the popup have to be in the foreground. If it is not, even for short amount of time (few seconds), if later prompt confirmed, it won't return success when returning signed os key half. * unit test coverage * unit test coverage * exclude test files from build * use electron `setAlwaysOnTop` instead of toggle * remove Windows os key half created with derive_key_material biometric function, that prompted Windows Hello. Moves Windows hello prompt into getBiometricKey. Witness key no longer needed. * windows crate formatting * remove biometric on app start for windows * failing os biometrics windows unit tests * cleanup of os biometrics windows unit tests * increased coverage of os biometrics windows unit tests * open Windows Hello prompt in the currently focused window, instead of always desktop app * conflict resolution after merge, typescript lint issues, increased test coverage. * backwards compatibility when require password on start was disabled * biometric unlock cancellation and error handling * biometric settings simplifications --- .../desktop_native/core/src/biometric/mod.rs | 90 ++++ .../core/src/biometric/windows.rs | 185 ++------ .../src/app/accounts/settings.component.html | 13 +- .../app/accounts/settings.component.spec.ts | 96 ++--- .../src/app/accounts/settings.component.ts | 42 +- .../biometrics/os-biometrics-linux.service.ts | 27 +- .../os-biometrics-windows.service.spec.ts | 402 ++++++++++++------ .../os-biometrics-windows.service.ts | 196 ++------- apps/desktop/src/locales/en/messages.json | 12 - apps/desktop/src/main.ts | 18 +- apps/desktop/tsconfig.json | 3 +- 11 files changed, 481 insertions(+), 603 deletions(-) diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index 79be43b1bfc..e4d51f5da9a 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -83,3 +83,93 @@ impl KeyMaterial { Ok(Sha256::digest(self.digest_material())) } } + +#[cfg(test)] +mod tests { + use crate::biometric::{decrypt, encrypt, KeyMaterial}; + use crate::crypto::CipherString; + use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; + use std::str::FromStr; + + fn key_material() -> KeyMaterial { + KeyMaterial { + os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), + client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), + } + } + + #[test] + fn test_encrypt() { + let key_material = key_material(); + let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned(); + let secret = encrypt("secret", &key_material, &iv_b64) + .unwrap() + .parse::<CipherString>() + .unwrap(); + + match secret { + CipherString::AesCbc256_B64 { iv, data: _ } => { + assert_eq!(iv_b64, base64_engine.encode(iv)); + } + _ => panic!("Invalid cipher string"), + } + } + + #[test] + fn test_decrypt() { + let secret = + CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt + let key_material = key_material(); + assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret") + } + + #[test] + fn key_material_produces_valid_key() { + let result = key_material().derive_key().unwrap(); + assert_eq!(result.len(), 32); + } + + #[test] + fn key_material_uses_os_part() { + let mut key_material = key_material(); + let result = key_material.derive_key().unwrap(); + key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } + + #[test] + fn key_material_uses_client_part() { + let mut key_material = key_material(); + let result = key_material.derive_key().unwrap(); + key_material.client_key_part_b64 = + Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } + + #[test] + fn key_material_produces_consistent_os_only_key() { + let mut key_material = key_material(); + key_material.client_key_part_b64 = None; + let result = key_material.derive_key().unwrap(); + assert_eq!( + result, + [ + 81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218, + 237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246 + ] + .into() + ); + } + + #[test] + fn key_material_produces_unique_os_only_key() { + let mut key_material = key_material(); + key_material.client_key_part_b64 = None; + let result = key_material.derive_key().unwrap(); + key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); + let result2 = key_material.derive_key().unwrap(); + assert_ne!(result, result2); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index 4c2e2c8ae25..99bec132edb 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -1,22 +1,18 @@ -use std::{ - ffi::c_void, - str::FromStr, - sync::{atomic::AtomicBool, Arc}, -}; +use std::{ffi::c_void, str::FromStr}; use anyhow::{anyhow, Result}; use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; use rand::RngCore; use sha2::{Digest, Sha256}; use windows::{ - core::{factory, h, HSTRING}, - Security::{ - Credentials::{ - KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, UI::*, - }, - Cryptography::CryptographicBuffer, + core::{factory, HSTRING}, + Security::Credentials::UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }, + Win32::{ + Foundation::HWND, System::WinRT::IUserConsentVerifierInterop, + UI::WindowsAndMessaging::GetForegroundWindow, }, - Win32::{Foundation::HWND, System::WinRT::IUserConsentVerifierInterop}, }; use windows_future::IAsyncOperation; @@ -25,10 +21,7 @@ use crate::{ crypto::CipherString, }; -use super::{ - decrypt, encrypt, - windows_focus::{focus_security_prompt, set_focus}, -}; +use super::{decrypt, encrypt, windows_focus::set_focus}; /// The Windows OS implementation of the biometric trait. pub struct Biometric {} @@ -44,9 +37,15 @@ impl super::BiometricTrait for Biometric { // should set the window to the foreground and focus it. set_focus(window); + // Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint + // unlock will not work. We get the current foreground window, which will either be the + // Bitwarden desktop app or the browser extension. + let foreground_window = unsafe { GetForegroundWindow() }; + let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?; - let operation: IAsyncOperation<UserConsentVerificationResult> = - unsafe { interop.RequestVerificationForWindowAsync(window, &HSTRING::from(message))? }; + let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe { + interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; let result = operation.get()?; match result { @@ -65,14 +64,6 @@ impl super::BiometricTrait for Biometric { } } - /// Derive the symmetric encryption key from the Windows Hello signature. - /// - /// This works by signing a static challenge string with Windows Hello protected key store. The - /// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the - /// Windows Hello protected keys. - /// - /// Windows will only sign the challenge if the user has successfully authenticated with Windows, - /// ensuring user presence. fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> { let challenge: [u8; 16] = match challenge_str { Some(challenge_str) => base64_engine @@ -81,51 +72,10 @@ impl super::BiometricTrait for Biometric { .map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?, None => random_challenge(), }; - let bitwarden = h!("Bitwarden"); - let result = KeyCredentialManager::RequestCreateAsync( - bitwarden, - KeyCredentialCreationOption::FailIfExists, - )? - .get()?; - - let result = match result.Status()? { - KeyCredentialStatus::CredentialAlreadyExists => { - KeyCredentialManager::OpenAsync(bitwarden)?.get()? - } - KeyCredentialStatus::Success => result, - _ => return Err(anyhow!("Failed to create key credential")), - }; - - let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?; - let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?; - focus_security_prompt(); - - let done = Arc::new(AtomicBool::new(false)); - let done_clone = done.clone(); - let _ = std::thread::spawn(move || loop { - if !done_clone.load(std::sync::atomic::Ordering::Relaxed) { - focus_security_prompt(); - std::thread::sleep(std::time::Duration::from_millis(500)); - } else { - break; - } - }); - - let signature = async_operation.get(); - done.store(true, std::sync::atomic::Ordering::Relaxed); - let signature = signature?; - - if signature.Status()? != KeyCredentialStatus::Success { - return Err(anyhow!("Failed to sign data")); - } - - let signature_buffer = signature.Result()?; - let mut signature_value = - windows::core::Array::<u8>::with_len(signature_buffer.Length().unwrap() as usize); - CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?; - - let key = Sha256::digest(&*signature_value); + // Uses a key derived from the iv. This key is not intended to add any security + // but only a place-holder + let key = Sha256::digest(challenge); let key_b64 = base64_engine.encode(key); let iv_b64 = base64_engine.encode(challenge); Ok(OsDerivedKey { key_b64, iv_b64 }) @@ -182,10 +132,9 @@ fn random_challenge() -> [u8; 16] { mod tests { use super::*; - use crate::biometric::{encrypt, BiometricTrait}; + use crate::biometric::BiometricTrait; #[test] - #[cfg(feature = "manual_test")] fn test_derive_key_material() { let iv_input = "l9fhDUP/wDJcKwmEzcb/3w=="; let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap(); @@ -195,7 +144,6 @@ mod tests { } #[test] - #[cfg(feature = "manual_test")] fn test_derive_key_material_no_iv() { let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap(); let key = base64_engine.decode(result.key_b64).unwrap(); @@ -221,38 +169,8 @@ mod tests { assert!(<Biometric as BiometricTrait>::available().await.unwrap()) } - #[test] - fn test_encrypt() { - let key_material = KeyMaterial { - os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), - client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), - }; - let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned(); - let secret = encrypt("secret", &key_material, &iv_b64) - .unwrap() - .parse::<CipherString>() - .unwrap(); - - match secret { - CipherString::AesCbc256_B64 { iv, data: _ } => { - assert_eq!(iv_b64, base64_engine.encode(iv)); - } - _ => panic!("Invalid cipher string"), - } - } - - #[test] - fn test_decrypt() { - let secret = - CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt - let key_material = KeyMaterial { - os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), - client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), - }; - assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret") - } - #[tokio::test] + #[cfg(feature = "manual_test")] async fn get_biometric_secret_requires_key() { let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await; assert!(result.is_err()); @@ -263,6 +181,7 @@ mod tests { } #[tokio::test] + #[cfg(feature = "manual_test")] async fn get_biometric_secret_handles_unencrypted_secret() { let test = "test"; let secret = "password"; @@ -284,6 +203,7 @@ mod tests { } #[tokio::test] + #[cfg(feature = "manual_test")] async fn get_biometric_secret_handles_encrypted_secret() { let test = "test"; let secret = @@ -316,61 +236,4 @@ mod tests { "Key material is required for Windows Hello protected keys" ); } - - fn key_material() -> KeyMaterial { - KeyMaterial { - os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), - client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()), - } - } - - #[test] - fn key_material_produces_valid_key() { - let result = key_material().derive_key().unwrap(); - assert_eq!(result.len(), 32); - } - - #[test] - fn key_material_uses_os_part() { - let mut key_material = key_material(); - let result = key_material.derive_key().unwrap(); - key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); - let result2 = key_material.derive_key().unwrap(); - assert_ne!(result, result2); - } - - #[test] - fn key_material_uses_client_part() { - let mut key_material = key_material(); - let result = key_material.derive_key().unwrap(); - key_material.client_key_part_b64 = - Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()); - let result2 = key_material.derive_key().unwrap(); - assert_ne!(result, result2); - } - - #[test] - fn key_material_produces_consistent_os_only_key() { - let mut key_material = key_material(); - key_material.client_key_part_b64 = None; - let result = key_material.derive_key().unwrap(); - assert_eq!( - result, - [ - 81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218, - 237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246 - ] - .into() - ); - } - - #[test] - fn key_material_produces_unique_os_only_key() { - let mut key_material = key_material(); - key_material.client_key_part_b64 = None; - let result = key_material.derive_key().unwrap(); - key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(); - let result2 = key_material.derive_key().unwrap(); - assert_ne!(result, result2); - } } diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 46cd323b071..473cfa73f1d 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -126,13 +126,13 @@ {{ biometricText | i18n }} </label> </div> - <small class="help-block" *ngIf="this.form.value.biometric && !this.isLinux">{{ - additionalBiometricSettingsText | i18n + <small class="help-block" *ngIf="this.form.value.biometric && this.isMac">{{ + "additionalTouchIdSettings" | i18n }}</small> </div> <div class="form-group" - *ngIf="supportsBiometric && this.form.value.biometric && !this.isLinux" + *ngIf="supportsBiometric && this.form.value.biometric && this.isMac" > <div class="checkbox form-group-child"> <label for="autoPromptBiometrics"> @@ -142,7 +142,7 @@ formControlName="autoPromptBiometrics" (change)="updateAutoPromptBiometrics()" /> - {{ autoPromptBiometricsText | i18n }} + {{ "autoPromptTouchId" | i18n }} </label> </div> </div> @@ -152,7 +152,7 @@ supportsBiometric && this.form.value.biometric && (userHasMasterPassword || (this.form.value.pin && userHasPinSet)) && - this.isWindows + false " > <div class="checkbox form-group-child"> @@ -170,9 +170,6 @@ } </label> </div> - <small class="help-block form-group-child" *ngIf="isWindows">{{ - "recommendedForSecurity" | i18n - }}</small> </div> </ng-container> </div> diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index 16ada3fbc07..819438eaa3b 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -271,74 +271,46 @@ describe("SettingsComponent", () => { vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true); }); - it("require password or pin on app start message when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = false; - policyService.policiesByType$.mockReturnValue(of([policy])); - platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); - i18nService.t.mockImplementation((id: string) => { - if (id === "requirePasswordOnStart") { - return "Require password or pin on app start"; - } else if (id === "requirePasswordWithoutPinOnStart") { - return "Require password on app start"; - } - return ""; + describe("windows desktop", () => { + beforeEach(() => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + + // Recreate component to apply the correct device + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; }); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); - await component.ngOnInit(); - fixture.detectChanges(); + it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { + const policy = new Policy(); + policy.type = PolicyType.RemoveUnlockWithPin; + policy.enabled = false; + policyService.policiesByType$.mockReturnValue(of([policy])); + pinServiceAbstraction.isPinSet.mockResolvedValue(true); - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), - ); - expect(requirePasswordOnStartLabelElement).not.toBeNull(); - expect(requirePasswordOnStartLabelElement.children).toHaveLength(1); - expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input"); - expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({ - id: "requirePasswordOnStart", - type: "checkbox", + await component.ngOnInit(); + fixture.detectChanges(); + + const requirePasswordOnStartLabelElement = fixture.debugElement.query( + By.css("label[for='requirePasswordOnStart']"), + ); + expect(requirePasswordOnStartLabelElement).toBeNull(); }); - const textNodes = requirePasswordOnStartLabelElement.childNodes - .filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE) - .map((node) => node.nativeNode.wholeText?.trim()); - expect(textNodes).toContain("Require password or pin on app start"); - }); - it("require password on app start message when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = true; - policyService.policiesByType$.mockReturnValue(of([policy])); - platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); - i18nService.t.mockImplementation((id: string) => { - if (id === "requirePasswordOnStart") { - return "Require password or pin on app start"; - } else if (id === "requirePasswordWithoutPinOnStart") { - return "Require password on app start"; - } - return ""; + it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { + const policy = new Policy(); + policy.type = PolicyType.RemoveUnlockWithPin; + policy.enabled = true; + policyService.policiesByType$.mockReturnValue(of([policy])); + pinServiceAbstraction.isPinSet.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + + const requirePasswordOnStartLabelElement = fixture.debugElement.query( + By.css("label[for='requirePasswordOnStart']"), + ); + expect(requirePasswordOnStartLabelElement).toBeNull(); }); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); - - await component.ngOnInit(); - fixture.detectChanges(); - - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), - ); - expect(requirePasswordOnStartLabelElement).not.toBeNull(); - expect(requirePasswordOnStartLabelElement.children).toHaveLength(1); - expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input"); - expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({ - id: "requirePasswordOnStart", - type: "checkbox", - }); - const textNodes = requirePasswordOnStartLabelElement.childNodes - .filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE) - .map((node) => node.nativeNode.wholeText?.trim()); - expect(textNodes).toContain("Require password on app start"); }); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 74f02f8b619..bbe5fb60719 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -78,6 +78,7 @@ export class SettingsComponent implements OnInit, OnDestroy { showOpenAtLoginOption = false; isWindows: boolean; isLinux: boolean; + isMac: boolean; enableTrayText: string; enableTrayDescText: string; @@ -170,31 +171,33 @@ export class SettingsComponent implements OnInit, OnDestroy { private configService: ConfigService, private validationService: ValidationService, ) { - const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; + this.isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; + this.isLinux = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop; + this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop; // Workaround to avoid ghosting trays https://github.com/electron/electron/issues/17622 this.requireEnableTray = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop; - const trayKey = isMac ? "enableMenuBar" : "enableTray"; + const trayKey = this.isMac ? "enableMenuBar" : "enableTray"; this.enableTrayText = this.i18nService.t(trayKey); this.enableTrayDescText = this.i18nService.t(trayKey + "Desc"); - const minToTrayKey = isMac ? "enableMinToMenuBar" : "enableMinToTray"; + const minToTrayKey = this.isMac ? "enableMinToMenuBar" : "enableMinToTray"; this.enableMinToTrayText = this.i18nService.t(minToTrayKey); this.enableMinToTrayDescText = this.i18nService.t(minToTrayKey + "Desc"); - const closeToTrayKey = isMac ? "enableCloseToMenuBar" : "enableCloseToTray"; + const closeToTrayKey = this.isMac ? "enableCloseToMenuBar" : "enableCloseToTray"; this.enableCloseToTrayText = this.i18nService.t(closeToTrayKey); this.enableCloseToTrayDescText = this.i18nService.t(closeToTrayKey + "Desc"); - const startToTrayKey = isMac ? "startToMenuBar" : "startToTray"; + const startToTrayKey = this.isMac ? "startToMenuBar" : "startToTray"; this.startToTrayText = this.i18nService.t(startToTrayKey); this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc"); this.showOpenAtLoginOption = !ipc.platform.isWindowsStore; // DuckDuckGo browser is only for macos initially - this.showDuckDuckGoIntegrationOption = isMac; + this.showDuckDuckGoIntegrationOption = this.isMac; const localeOptions: any[] = []; this.i18nService.supportedTranslationLocales.forEach((locale) => { @@ -239,7 +242,6 @@ export class SettingsComponent implements OnInit, OnDestroy { async ngOnInit() { this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - this.isLinux = (await this.platformUtilsService.getDevice()) === DeviceType.LinuxDesktop; // Autotype is for Windows initially const isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop; @@ -250,8 +252,6 @@ export class SettingsComponent implements OnInit, OnDestroy { this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword(); - this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop; - this.currentUserEmail = activeAccount.email; this.currentUserId = activeAccount.id; @@ -911,28 +911,4 @@ export class SettingsComponent implements OnInit, OnDestroy { throw new Error("Unsupported platform"); } } - - get autoPromptBiometricsText() { - switch (this.platformUtilsService.getDevice()) { - case DeviceType.MacOsDesktop: - return "autoPromptTouchId"; - case DeviceType.WindowsDesktop: - return "autoPromptWindowsHello"; - case DeviceType.LinuxDesktop: - return "autoPromptPolkit"; - default: - throw new Error("Unsupported platform"); - } - } - - get additionalBiometricSettingsText() { - switch (this.platformUtilsService.getDevice()) { - case DeviceType.MacOsDesktop: - return "additionalTouchIdSettings"; - case DeviceType.WindowsDesktop: - return "additionalWindowsHelloSettings"; - default: - throw new Error("Unsupported platform"); - } - } } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index c310f337182..26a8e949f38 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -34,6 +34,7 @@ const policyFileName = "com.bitwarden.Bitwarden.policy"; const policyPath = "/usr/share/polkit-1/actions/"; const SERVICE = "Bitwarden_biometric"; + function getLookupKeyForUser(userId: UserId): string { return `${userId}_user_biometric`; } @@ -45,16 +46,18 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { private cryptoFunctionService: CryptoFunctionService, private logService: LogService, ) {} + private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; private clientKeyHalves = new Map<UserId, Uint8Array | null>(); async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> { - const clientKeyPartB64 = Utils.fromBufferToB64( - await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key), - ); - const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); + const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); + + const storageDetails = await this.getStorageDetails({ + clientKeyHalfB64: clientKeyHalf ? Utils.fromBufferToB64(clientKeyHalf) : undefined, + }); await biometrics.setBiometricSecret( SERVICE, getLookupKeyForUser(userId), @@ -63,6 +66,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { storageDetails.ivB64, ); } + async deleteBiometricKey(userId: UserId): Promise<void> { try { await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId)); @@ -91,11 +95,15 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { if (value == null || value == "") { return null; } else { - const clientKeyHalf = this.clientKeyHalves.get(userId); - const clientKeyPartB64 = Utils.fromBufferToB64(clientKeyHalf); + let clientKeyPartB64: string | null = null; + if (this.clientKeyHalves.has(userId)) { + clientKeyPartB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!); + } const encValue = new EncString(value); this.setIv(encValue.iv); - const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 }); + const storageDetails = await this.getStorageDetails({ + clientKeyHalfB64: clientKeyPartB64 ?? undefined, + }); const storedValue = await biometrics.getBiometricSecret( SERVICE, getLookupKeyForUser(userId), @@ -169,7 +177,6 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { if (this._osKeyHalf == null) { const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); - // osKeyHalf is based on the iv and in contrast to windows is not locked behind user verification! this._osKeyHalf = keyMaterial.keyB64; this._iv = keyMaterial.ivB64; } @@ -209,8 +216,8 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { } if (clientKeyHalf == null) { // Set a key half if it doesn't exist - const keyBytes = await this.cryptoFunctionService.randomBytes(32); - const encKey = await this.encryptService.encryptBytes(keyBytes, key); + clientKeyHalf = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key); await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts index 674d97bf696..f301efc70e7 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.spec.ts @@ -1,51 +1,65 @@ +import { randomBytes } from "node:crypto"; + +import { BrowserWindow } from "electron"; import { mock } from "jest-mock-extended"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; -import { passwords } from "@bitwarden/desktop-napi"; +import { biometrics, passwords } from "@bitwarden/desktop-napi"; import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management"; import { WindowMain } from "../../main/window.main"; import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; -jest.mock("@bitwarden/desktop-napi", () => ({ - biometrics: { - available: jest.fn(), - setBiometricSecret: jest.fn(), - getBiometricSecret: jest.fn(), - deleteBiometricSecret: jest.fn(), - prompt: jest.fn(), - deriveKeyMaterial: jest.fn(), - }, - passwords: { - getPassword: jest.fn(), - deletePassword: jest.fn(), - isAvailable: jest.fn(), - PASSWORD_NOT_FOUND: "Password not found", - }, -})); +import OsDerivedKey = biometrics.OsDerivedKey; + +jest.mock("@bitwarden/desktop-napi", () => { + return { + biometrics: { + available: jest.fn().mockResolvedValue(true), + getBiometricSecret: jest.fn().mockResolvedValue(""), + setBiometricSecret: jest.fn().mockResolvedValue(""), + deleteBiometricSecret: jest.fn(), + deriveKeyMaterial: jest.fn().mockResolvedValue({ + keyB64: "", + ivB64: "", + }), + prompt: jest.fn().mockResolvedValue(true), + }, + passwords: { + getPassword: jest.fn().mockResolvedValue(null), + deletePassword: jest.fn().mockImplementation(() => {}), + isAvailable: jest.fn(), + PASSWORD_NOT_FOUND: "Password not found", + }, + }; +}); + +describe("OsBiometricsServiceWindows", function () { + const i18nService = mock<I18nService>(); + const windowMain = mock<WindowMain>(); + const browserWindow = mock<BrowserWindow>(); + const encryptionService: EncryptService = mock<EncryptService>(); + const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>(); + const biometricStateService: BiometricStateService = mock<BiometricStateService>(); + const logService = mock<LogService>(); -describe("OsBiometricsServiceWindows", () => { let service: OsBiometricsServiceWindows; - let i18nService: I18nService; - let windowMain: WindowMain; - let logService: LogService; - let biometricStateService: BiometricStateService; - const mockUserId = "test-user-id" as UserId; + const key = new SymmetricCryptoKey(new Uint8Array(64)); + const userId = "test-user-id" as UserId; + const serviceKey = "Bitwarden_biometric"; + const storageKey = `${userId}_user_biometric`; beforeEach(() => { - i18nService = mock<I18nService>(); - windowMain = mock<WindowMain>(); - logService = mock<LogService>(); - biometricStateService = mock<BiometricStateService>(); - const encryptionService = mock<EncryptService>(); - const cryptoFunctionService = mock<CryptoFunctionService>(); + windowMain.win = browserWindow; + service = new OsBiometricsServiceWindows( i18nService, windowMain, @@ -62,20 +76,13 @@ describe("OsBiometricsServiceWindows", () => { describe("getBiometricsFirstUnlockStatusForUser", () => { const userId = "test-user-id" as UserId; - it("should return Available when requirePasswordOnRestart is false", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false); - const result = await service.getBiometricsFirstUnlockStatusForUser(userId); - expect(result).toBe(BiometricsStatus.Available); - }); - it("should return Available when requirePasswordOnRestart is true and client key half is set", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + it("should return Available when client key half is set", async () => { (service as any).clientKeyHalves = new Map<string, Uint8Array>(); (service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4])); const result = await service.getBiometricsFirstUnlockStatusForUser(userId); expect(result).toBe(BiometricsStatus.Available); }); - it("should return UnlockNeeded when requirePasswordOnRestart is true and client key half is not set", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); + it("should return UnlockNeeded when client key half is not set", async () => { (service as any).clientKeyHalves = new Map<string, Uint8Array>(); const result = await service.getBiometricsFirstUnlockStatusForUser(userId); expect(result).toBe(BiometricsStatus.UnlockNeeded); @@ -83,32 +90,7 @@ describe("OsBiometricsServiceWindows", () => { }); describe("getOrCreateBiometricEncryptionClientKeyHalf", () => { - const userId = "test-user-id" as UserId; - const key = new SymmetricCryptoKey(new Uint8Array(64)); - let encryptionService: EncryptService; - let cryptoFunctionService: CryptoFunctionService; - - beforeEach(() => { - encryptionService = mock<EncryptService>(); - cryptoFunctionService = mock<CryptoFunctionService>(); - service = new OsBiometricsServiceWindows( - mock<I18nService>(), - windowMain, - mock<LogService>(), - biometricStateService, - encryptionService, - cryptoFunctionService, - ); - }); - - it("should return null if getRequirePasswordOnRestart is false", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false); - const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - expect(result).toBeNull(); - }); - it("should return cached key half if already present", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); const cachedKeyHalf = new Uint8Array([10, 20, 30]); (service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf); const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); @@ -116,7 +98,6 @@ describe("OsBiometricsServiceWindows", () => { }); it("should decrypt and return existing encrypted client key half", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); biometricStateService.getEncryptedClientKeyHalf = jest .fn() .mockResolvedValue(new Uint8Array([1, 2, 3])); @@ -132,7 +113,6 @@ describe("OsBiometricsServiceWindows", () => { }); it("should generate, encrypt, store, and cache a new key half if none exists", async () => { - biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true); biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null); const randomBytes = new Uint8Array([7, 8, 9]); cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes); @@ -148,101 +128,251 @@ describe("OsBiometricsServiceWindows", () => { encrypted, userId, ); - expect(result).toBeNull(); - expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull(); + expect(result).toEqual(randomBytes); + expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes); }); }); + describe("supportsBiometrics", () => { + it("should return true if biometrics are available", async () => { + biometrics.available = jest.fn().mockResolvedValue(true); + + const result = await service.supportsBiometrics(); + + expect(result).toBe(true); + }); + + it("should return false if biometrics are not available", async () => { + biometrics.available = jest.fn().mockResolvedValue(false); + + const result = await service.supportsBiometrics(); + + expect(result).toBe(false); + }); + }); + + describe("getBiometricKey", () => { + beforeEach(() => { + biometrics.prompt = jest.fn().mockResolvedValue(true); + }); + + it("should return null when unsuccessfully authenticated biometrics", async () => { + biometrics.prompt = jest.fn().mockResolvedValue(false); + + const result = await service.getBiometricKey(userId); + + expect(result).toBeNull(); + }); + + it.each([null, undefined, ""])( + "should throw error when no biometric key is found '%s'", + async (password) => { + passwords.getPassword = jest.fn().mockResolvedValue(password); + + await expect(service.getBiometricKey(userId)).rejects.toThrow( + "Biometric key not found for user", + ); + + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + }, + ); + + it.each([[false], [true]])( + "should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s", + async (haveClientKeyHalves) => { + const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); + if (haveClientKeyHalves) { + service["clientKeyHalves"].set(userId, clientKeyHalveBytes); + } + const biometricKey = key.toBase64(); + passwords.getPassword = jest.fn().mockResolvedValue(biometricKey); + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ + keyB64: "testKeyB64", + ivB64: "testIvB64", + } satisfies OsDerivedKey); + + const result = await service.getBiometricKey(userId); + + expect(result.toBase64()).toBe(biometricKey); + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + expect(biometrics.setBiometricSecret).toHaveBeenCalledWith( + serviceKey, + storageKey, + biometricKey, + { + osKeyPartB64: "testKeyB64", + clientKeyPartB64: haveClientKeyHalves + ? Utils.fromBufferToB64(clientKeyHalveBytes) + : undefined, + }, + "testIvB64", + ); + }, + ); + + it.each([[false], [true]])( + "should return the biometricKey if password is encrypted and cached clientKeyHalves is %s", + async (haveClientKeyHalves) => { + const clientKeyHalveBytes = new Uint8Array([1, 2, 3]); + if (haveClientKeyHalves) { + service["clientKeyHalves"].set(userId, clientKeyHalveBytes); + } + const biometricKey = key.toBase64(); + const biometricKeyEncrypted = "2.testId|data|mac"; + passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted); + biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey); + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({ + keyB64: "testKeyB64", + ivB64: "testIvB64", + } satisfies OsDerivedKey); + + const result = await service.getBiometricKey(userId); + + expect(result.toBase64()).toBe(biometricKey); + expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey); + expect(biometrics.setBiometricSecret).not.toHaveBeenCalled(); + expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, { + osKeyPartB64: "testKeyB64", + clientKeyPartB64: haveClientKeyHalves + ? Utils.fromBufferToB64(clientKeyHalveBytes) + : undefined, + }); + }, + ); + }); + describe("deleteBiometricKey", () => { const serviceName = "Bitwarden_biometric"; const keyName = "test-user-id_user_biometric"; - const witnessKeyName = "test-user-id_user_biometric_witness"; it("should delete biometric key successfully", async () => { - await service.deleteBiometricKey(mockUserId); + await service.deleteBiometricKey(userId); expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName); }); - it.each([ - [false, false], - [false, true], - [true, false], - ])( - "should not throw error if key found: %s and witness key found: %s", - async (keyFound, witnessKeyFound) => { - passwords.deletePassword = jest.fn().mockImplementation((_, account) => { - if (account === keyName) { - if (!keyFound) { - throw new Error(passwords.PASSWORD_NOT_FOUND); - } - return Promise.resolve(); - } - if (account === witnessKeyName) { - if (!witnessKeyFound) { - throw new Error(passwords.PASSWORD_NOT_FOUND); - } - return Promise.resolve(); - } - throw new Error("Unexpected key"); - }); + it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => { + if (!keyFound) { + passwords.deletePassword = jest + .fn() + .mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND)); + } - await service.deleteBiometricKey(mockUserId); + await service.deleteBiometricKey(userId); - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName); - if (!keyFound) { - expect(logService.debug).toHaveBeenCalledWith( - "[OsBiometricService] Biometric key %s not found for service %s.", - keyName, - serviceName, - ); - } - if (!witnessKeyFound) { - expect(logService.debug).toHaveBeenCalledWith( - "[OsBiometricService] Biometric witness key %s not found for service %s.", - witnessKeyName, - serviceName, - ); - } - }, - ); + expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); + if (!keyFound) { + expect(logService.debug).toHaveBeenCalledWith( + "[OsBiometricService] Biometric key %s not found for service %s.", + keyName, + serviceName, + ); + } + }); it("should throw error when deletePassword for key throws unexpected errors", async () => { const error = new Error("Unexpected error"); - passwords.deletePassword = jest.fn().mockImplementation((_, account) => { - if (account === keyName) { - throw error; - } - if (account === witnessKeyName) { - return Promise.resolve(); - } - throw new Error("Unexpected key"); - }); + passwords.deletePassword = jest.fn().mockRejectedValue(error); - await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); + await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error); expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - expect(passwords.deletePassword).not.toHaveBeenCalledWith(serviceName, witnessKeyName); + }); + }); + + describe("authenticateBiometric", () => { + const hwnd = randomBytes(32).buffer; + const consentMessage = "Test Windows Hello Consent Message"; + + beforeEach(() => { + windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd); + i18nService.t.mockReturnValue(consentMessage); }); - it("should throw error when deletePassword for witness key throws unexpected errors", async () => { - const error = new Error("Unexpected error"); - passwords.deletePassword = jest.fn().mockImplementation((_, account) => { - if (account === keyName) { - return Promise.resolve(); - } - if (account === witnessKeyName) { - throw error; - } - throw new Error("Unexpected key"); - }); + it("should return true when biometric authentication is successful", async () => { + const result = await service.authenticateBiometric(); - await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error); + expect(result).toBe(true); + expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); + }); - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName); - expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName); + it("should return false when biometric authentication fails", async () => { + biometrics.prompt = jest.fn().mockResolvedValue(false); + + const result = await service.authenticateBiometric(); + + expect(result).toBe(false); + expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage); + }); + }); + + describe("getStorageDetails", () => { + it.each([ + ["testClientKeyHalfB64", "testIvB64"], + [undefined, "testIvB64"], + ["testClientKeyHalfB64", null], + [undefined, null], + ])( + "should derive key material and ivB64 and return it when os key half not saved yet", + async (clientKeyHalfB64, ivB64) => { + service["setIv"](ivB64); + + const derivedKeyMaterial = { + keyB64: "derivedKeyB64", + ivB64: "derivedIvB64", + }; + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); + + const result = await service["getStorageDetails"]({ clientKeyHalfB64 }); + + expect(result).toEqual({ + key_material: { + osKeyPartB64: derivedKeyMaterial.keyB64, + clientKeyPartB64: clientKeyHalfB64, + }, + ivB64: derivedKeyMaterial.ivB64, + }); + expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64); + expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64); + expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64); + }, + ); + + it("should throw an error when deriving key material and returned iv is null", async () => { + service["setIv"]("testIvB64"); + + const derivedKeyMaterial = { + keyB64: "derivedKeyB64", + ivB64: null as string | undefined | null, + }; + biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial); + + await expect( + service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }), + ).rejects.toThrow("Initialization Vector is null"); + + expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64"); + }); + }); + + describe("setIv", () => { + it("should set the iv and reset the osKeyHalf", () => { + const iv = "testIv"; + service["_osKeyHalf"] = "testOsKeyHalf"; + + service["setIv"](iv); + + expect(service["_iv"]).toBe(iv); + expect(service["_osKeyHalf"]).toBeNull(); + }); + + it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => { + service["_osKeyHalf"] = "testOsKeyHalf"; + + service["setIv"](undefined); + + expect(service["_iv"]).toBeNull(); + expect(service["_osKeyHalf"]).toBeNull(); }); }); }); diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index 65abced1526..897304c9f61 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -3,7 +3,6 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; @@ -14,10 +13,8 @@ import { WindowMain } from "../../main/window.main"; import { OsBiometricService } from "./os-biometrics.service"; -const KEY_WITNESS_SUFFIX = "_witness"; -const WITNESS_VALUE = "known key"; - const SERVICE = "Bitwarden_biometric"; + function getLookupKeyForUser(userId: UserId): string { return `${userId}_user_biometric`; } @@ -43,18 +40,25 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> { - const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); - let clientKeyHalfB64: string | null = null; - if (this.clientKeyHalves.has(userId)) { - clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)); + const success = await this.authenticateBiometric(); + if (!success) { + return null; } + const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId)); if (value == null || value == "") { - return null; - } else if (!EncString.isSerializedEncString(value)) { + throw new Error("Biometric key not found for user"); + } + + let clientKeyHalfB64: string | null = null; + if (this.clientKeyHalves.has(userId)) { + clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!); + } + + if (!EncString.isSerializedEncString(value)) { // Update to format encrypted with client key half const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64, + clientKeyHalfB64: clientKeyHalfB64 ?? undefined, }); await biometrics.setBiometricSecret( @@ -69,7 +73,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { const encValue = new EncString(value); this.setIv(encValue.iv); const storageDetails = await this.getStorageDetails({ - clientKeyHalfB64: clientKeyHalfB64, + clientKeyHalfB64: clientKeyHalfB64 ?? undefined, }); return SymmetricCryptoKey.fromString( await biometrics.getBiometricSecret( @@ -84,35 +88,16 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> { const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key); - if ( - await this.valueUpToDate({ - value: key, - clientKeyPartB64: Utils.fromBufferToB64(clientKeyHalf), - service: SERVICE, - storageKey: getLookupKeyForUser(userId), - }) - ) { - return; - } - const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf), }); - const storedValue = await biometrics.setBiometricSecret( + await biometrics.setBiometricSecret( SERVICE, getLookupKeyForUser(userId), key.toBase64(), storageDetails.key_material, storageDetails.ivB64, ); - const parsedStoredValue = new EncString(storedValue); - await this.storeValueWitness( - key, - parsedStoredValue, - SERVICE, - getLookupKeyForUser(userId), - Utils.fromBufferToB64(clientKeyHalf), - ); } async deleteBiometricKey(userId: UserId): Promise<void> { @@ -129,21 +114,11 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { throw e; } } - try { - await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX); - } catch (e) { - if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { - this.logService.debug( - "[OsBiometricService] Biometric witness key %s not found for service %s.", - getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX, - SERVICE, - ); - } else { - throw e; - } - } } + /** + * Prompts Windows Hello + */ async authenticateBiometric(): Promise<boolean> { const hwnd = this.windowMain.win.getNativeWindowHandle(); return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage")); @@ -155,7 +130,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { clientKeyHalfB64: string | undefined; }): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> { if (this._osKeyHalf == null) { - // Prompts Windows Hello const keyMaterial = await biometrics.deriveKeyMaterial(this._iv); this._osKeyHalf = keyMaterial.keyB64; this._iv = keyMaterial.ivB64; @@ -187,118 +161,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { this._osKeyHalf = null; } - /** - * Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date. - * - * @param unencryptedValue The key to store - * @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key. - * @param service The service to store the witness key under - * @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX} - * @returns - */ - private async storeValueWitness( - unencryptedValue: SymmetricCryptoKey, - encryptedValue: EncString, - service: string, - storageKey: string, - clientKeyPartB64: string | undefined, - ) { - if (encryptedValue.iv == null) { - return; - } - - const storageDetails = { - keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64), - ivB64: encryptedValue.iv, - }; - await biometrics.setBiometricSecret( - service, - storageKey + KEY_WITNESS_SUFFIX, - WITNESS_VALUE, - storageDetails.keyMaterial, - storageDetails.ivB64, - ); - } - - /** - * Uses a witness key stored alongside the encrypted value to determine if the value is up to date. - * @param value The value being validated - * @param service The service the value is stored under - * @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX} - * @returns Boolean indicating if the value is up to date. - */ - // Uses a witness key stored alongside the encrypted value to determine if the value is up to date. - private async valueUpToDate({ - value, - clientKeyPartB64, - service, - storageKey, - }: { - value: SymmetricCryptoKey; - clientKeyPartB64: string | undefined; - service: string; - storageKey: string; - }): Promise<boolean> { - const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64); - if (witnessKeyMaterial == null) { - return false; - } - - let witness = null; - try { - witness = await biometrics.getBiometricSecret( - service, - storageKey + KEY_WITNESS_SUFFIX, - witnessKeyMaterial, - ); - } catch (e) { - if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) { - this.logService.debug( - "[OsBiometricService] Biometric witness key %s not found for service %s, value is not up to date.", - storageKey + KEY_WITNESS_SUFFIX, - service, - ); - } else { - this.logService.error( - "[OsBiometricService] Error retrieving witness key, assuming value is not up to date.", - e, - ); - } - return false; - } - - if (witness === WITNESS_VALUE) { - return true; - } - - return false; - } - - /** Derives a witness key from a symmetric key being stored for biometric protection */ - private witnessKeyMaterial( - symmetricKey: SymmetricCryptoKey, - clientKeyPartB64: string | undefined, - ): biometrics.KeyMaterial { - let key = null; - const innerKey = symmetricKey.inner(); - if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { - key = Utils.fromBufferToB64(innerKey.authenticationKey); - } else { - key = Utils.fromBufferToB64(innerKey.encryptionKey); - } - - const result = { - osKeyPartB64: key, - clientKeyPartB64, - }; - - // napi-rs fails to convert null values - if (result.clientKeyPartB64 == null) { - delete result.clientKeyPartB64; - } - return result; - } - async needsSetup() { return false; } @@ -312,14 +174,9 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { async getOrCreateBiometricEncryptionClientKeyHalf( userId: UserId, key: SymmetricCryptoKey, - ): Promise<Uint8Array | null> { - const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); - if (!requireClientKeyHalf) { - return null; - } - + ): Promise<Uint8Array> { if (this.clientKeyHalves.has(userId)) { - return this.clientKeyHalves.get(userId); + return this.clientKeyHalves.get(userId)!; } // Retrieve existing key half if it exists @@ -331,8 +188,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } if (clientKeyHalf == null) { // Set a key half if it doesn't exist - const keyBytes = await this.cryptoFunctionService.randomBytes(32); - const encKey = await this.encryptService.encryptBytes(keyBytes, key); + clientKeyHalf = await this.cryptoFunctionService.randomBytes(32); + const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key); await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId); } @@ -342,11 +199,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { } async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> { - const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId); - if (!requireClientKeyHalf) { - return BiometricsStatus.Available; - } - if (this.clientKeyHalves.has(userId)) { return BiometricsStatus.Available; } else { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 1ad0dcf308f..e5e6dcb2882 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1813,9 +1813,6 @@ "unlockWithWindowsHello": { "message": "Unlock with Windows Hello" }, - "additionalWindowsHelloSettings": { - "message": "Additional Windows Hello settings" - }, "unlockWithPolkit": { "message": "Unlock with system authentication" }, @@ -1831,12 +1828,6 @@ "touchIdConsentMessage": { "message": "unlock your vault" }, - "autoPromptWindowsHello": { - "message": "Ask for Windows Hello on app start" - }, - "autoPromptPolkit": { - "message": "Ask for system authentication on launch" - }, "autoPromptTouchId": { "message": "Ask for Touch ID on app start" }, @@ -1846,9 +1837,6 @@ "requirePasswordWithoutPinOnStart": { "message": "Require password on app start" }, - "recommendedForSecurity": { - "message": "Recommended for security." - }, "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index eee5e3c84f2..9b5aa0b31e9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -201,6 +201,16 @@ export class Main { this.logService, true, ); + + this.windowMain = new WindowMain( + biometricStateService, + this.logService, + this.storageService, + this.desktopSettingsService, + (arg) => this.processDeepLink(arg), + (win) => this.trayMain.setupWindowListeners(win), + ); + this.biometricsService = new MainBiometricsService( this.i18nService, this.windowMain, @@ -211,14 +221,6 @@ export class Main { this.mainCryptoFunctionService, ); - this.windowMain = new WindowMain( - biometricStateService, - this.logService, - this.storageService, - this.desktopSettingsService, - (arg) => this.processDeepLink(arg), - (win) => this.trayMain.setupWindowListeners(win), - ); this.messagingMain = new MessagingMain(this, this.desktopSettingsService); this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 7db3e84e451..70a59ad164c 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,5 +3,6 @@ "angularCompilerOptions": { "strictTemplates": true }, - "include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"] + "include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"], + "exclude": ["src/**/*.spec.ts"] } From 8365efb47324524562af02ee4994f4e5c59cbc3a Mon Sep 17 00:00:00 2001 From: Bryan Cunningham <bcunningham@bitwarden.com> Date: Mon, 21 Jul 2025 14:04:21 -0400 Subject: [PATCH 26/54] remove absolute positioning of radio indicator (#15623) --- libs/components/src/radio-button/radio-input.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/components/src/radio-button/radio-input.component.ts b/libs/components/src/radio-button/radio-input.component.ts index 6140969d651..33100db1679 100644 --- a/libs/components/src/radio-button/radio-input.component.ts +++ b/libs/components/src/radio-button/radio-input.component.ts @@ -32,6 +32,7 @@ export class RadioInputComponent implements BitFormControlAbstraction { "tw-border-secondary-600", "tw-w-[1.12rem]", "tw-h-[1.12rem]", + "!tw-p-[.125rem]", "tw-flex-none", // Flexbox fix for bit-form-control "hover:tw-border-2", @@ -45,9 +46,8 @@ export class RadioInputComponent implements BitFormControlAbstraction { "before:tw-content-['']", "before:tw-transition", "before:tw-block", - "before:tw-absolute", "before:tw-rounded-full", - "before:tw-inset-[2px]", + "before:tw-size-full", "disabled:tw-cursor-auto", "disabled:tw-bg-secondary-100", From b33bdd60aec8674bdd2c059c39252941df82ecfa Mon Sep 17 00:00:00 2001 From: Vijay Oommen <voommen@livefront.com> Date: Mon, 21 Jul 2025 13:45:48 -0500 Subject: [PATCH 27/54] [PM-23758] Api method to save and retrieve report summary (#15705) --- .../risk-insights/models/password-health.ts | 7 ++ .../risk-insights-api.service.spec.ts | 83 +++++++++++++++++++ .../services/risk-insights-api.service.ts | 45 ++++++++++ 3 files changed, 135 insertions(+) create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index 62eb0122dca..6be24d60b89 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -158,6 +158,13 @@ export interface PasswordHealthReportApplicationsRequest { url: string; } +export interface EncryptedDataModel { + organizationId: OrganizationId; + encryptedData: string; + encryptionKey: string; + date: Date; +} + // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums export enum DrawerType { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts new file mode 100644 index 00000000000..ef9fc768944 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -0,0 +1,83 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { EncryptedDataModel } from "../models/password-health"; + +import { RiskInsightsApiService } from "./risk-insights-api.service"; + +describe("RiskInsightsApiService", () => { + let service: RiskInsightsApiService; + const mockApiService = mock<ApiService>(); + + beforeEach(() => { + service = new RiskInsightsApiService(mockApiService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("getRiskInsightsSummary", () => { + it("should call apiService.send with correct parameters and return an Observable", (done) => { + const orgId = "org123"; + const minDate = new Date("2024-01-01"); + const maxDate = new Date("2024-01-31"); + const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel]; + + mockApiService.send.mockResolvedValueOnce(mockResponse); + + service.getRiskInsightsSummary(orgId, minDate, maxDate).subscribe((result) => { + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `organization-report-summary/org123?from=2024-01-01&to=2024-01-31`, + null, + true, + true, + ); + expect(result).toEqual(mockResponse); + done(); + }); + }); + }); + + describe("saveRiskInsightsSummary", () => { + it("should call apiService.send with correct parameters and return an Observable", (done) => { + const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel; + + mockApiService.send.mockResolvedValueOnce(undefined); + + service.saveRiskInsightsSummary(data).subscribe((result) => { + expect(mockApiService.send).toHaveBeenCalledWith( + "POST", + "organization-report-summary", + data, + true, + true, + ); + expect(result).toBeUndefined(); + done(); + }); + }); + }); + + describe("updateRiskInsightsSummary", () => { + it("should call apiService.send with correct parameters and return an Observable", (done) => { + const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel; + + mockApiService.send.mockResolvedValueOnce(undefined); + + service.updateRiskInsightsSummary(data).subscribe((result) => { + expect(mockApiService.send).toHaveBeenCalledWith( + "PUT", + "organization-report-summary", + data, + true, + true, + ); + expect(result).toBeUndefined(); + done(); + }); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts new file mode 100644 index 00000000000..0d69947b826 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts @@ -0,0 +1,45 @@ +import { from, Observable } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { EncryptedDataModel } from "../models/password-health"; + +export class RiskInsightsApiService { + constructor(private apiService: ApiService) {} + + getRiskInsightsSummary( + orgId: string, + minDate: Date, + maxDate: Date, + ): Observable<EncryptedDataModel[]> { + const minDateStr = minDate.toISOString().split("T")[0]; + const maxDateStr = maxDate.toISOString().split("T")[0]; + const dbResponse = this.apiService.send( + "GET", + `organization-report-summary/${orgId.toString()}?from=${minDateStr}&to=${maxDateStr}`, + null, + true, + true, + ); + + return from(dbResponse as Promise<EncryptedDataModel[]>); + } + + saveRiskInsightsSummary(data: EncryptedDataModel): Observable<void> { + const dbResponse = this.apiService.send( + "POST", + "organization-report-summary", + data, + true, + true, + ); + + return from(dbResponse as Promise<void>); + } + + updateRiskInsightsSummary(data: EncryptedDataModel): Observable<void> { + const dbResponse = this.apiService.send("PUT", "organization-report-summary", data, true, true); + + return from(dbResponse as Promise<void>); + } +} From 83f9061474f54cbb4923f907d3bd83396ad6a0c1 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:54:28 -0400 Subject: [PATCH 28/54] [BRE-831] migrate secrets akv (#15158) --- .github/workflows/build-browser-target.yml | 5 + .github/workflows/build-browser.yml | 60 ++++++-- .github/workflows/build-cli-target.yml | 5 + .github/workflows/build-cli.yml | 61 ++++++-- .github/workflows/build-desktop-target.yml | 5 + .github/workflows/build-desktop.yml | 136 ++++++++++++++---- .github/workflows/build-web-target.yml | 6 + .github/workflows/build-web.yml | 60 +++++--- .github/workflows/chromatic.yml | 26 +++- .github/workflows/crowdin-pull.yml | 41 ++++-- .github/workflows/deploy-web.yml | 77 ++++++---- .github/workflows/lint-crowdin-config.yml | 22 ++- .github/workflows/publish-cli.yml | 53 +++++-- .github/workflows/publish-desktop.yml | 50 +++++-- .github/workflows/publish-web.yml | 30 +++- .github/workflows/release-desktop-beta.yml | 112 ++++++++++++--- .github/workflows/repository-management.yml | 54 ++++++- .../retrieve-current-desktop-rollout.yml | 13 +- .github/workflows/staged-rollout-desktop.yml | 13 +- .github/workflows/version-auto-bump.yml | 24 +++- 20 files changed, 680 insertions(+), 173 deletions(-) diff --git a/.github/workflows/build-browser-target.yml b/.github/workflows/build-browser-target.yml index a2ae48d419b..ef3beef4b8b 100644 --- a/.github/workflows/build-browser-target.yml +++ b/.github/workflows/build-browser-target.yml @@ -28,6 +28,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Build Browser @@ -35,4 +37,7 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build-browser.yml secrets: inherit + permissions: + contents: read + id-token: write diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 40b03d9e753..bd7d70e8543 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -41,7 +41,8 @@ defaults: run: shell: bash -permissions: {} +permissions: + contents: read jobs: setup: @@ -77,10 +78,8 @@ jobs: - name: Check secrets id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT @@ -302,6 +301,9 @@ jobs: build-safari: name: Build Safari runs-on: macos-13 + permissions: + contents: read + id-token: write needs: - setup - locales-test @@ -327,10 +329,19 @@ jobs: node --version npm --version - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + 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: "KEYCHAIN-PASSWORD" - name: Download Provisioning Profiles secrets env: @@ -366,9 +377,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -440,6 +454,10 @@ jobs: name: Crowdin Push if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 + permissions: + contents: write + pull-requests: write + id-token: write needs: - build - build-safari @@ -449,10 +467,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -461,6 +481,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Upload Sources uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 env: @@ -478,6 +501,9 @@ jobs: name: Check for failures if: always() runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write needs: - setup - locales-test @@ -493,11 +519,13 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Login to Azure - Prod Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure if: failure() + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -507,6 +535,10 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + if: failure() + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() diff --git a/.github/workflows/build-cli-target.yml b/.github/workflows/build-cli-target.yml index 6b493d4e6d9..54865ddaddd 100644 --- a/.github/workflows/build-cli-target.yml +++ b/.github/workflows/build-cli-target.yml @@ -28,6 +28,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Build CLI @@ -35,4 +37,7 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build-cli.yml secrets: inherit + permissions: + contents: read + id-token: write diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index ac314a4c33a..b31b22b926e 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -78,10 +78,8 @@ jobs: - name: Check secrets id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT @@ -108,6 +106,10 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} _WIN_PKG_FETCH_VERSION: 20.11.1 _WIN_PKG_VERSION: 3.5 + permissions: + contents: read + id-token: write + steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -156,9 +158,11 @@ jobs: - name: Login to Azure if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Get certificates if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} @@ -168,10 +172,21 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 + - name: Get Azure Key Vault secrets + id: get-kv-secrets + if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-clients + secrets: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -199,13 +214,13 @@ jobs: run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ secrets.APP_STORE_CONNECT_AUTH_KEY }} + ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} EOF - name: Notarize app if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }} env: - APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8 run: | @@ -261,6 +276,9 @@ jobs: { build_prefix: "bit", artifact_prefix: "", readable: "commercial license" } ] runs-on: windows-2022 + permissions: + contents: read + id-token: write needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} @@ -344,11 +362,13 @@ jobs: ResourceHacker -open version-info.rc -save version-info.res -action compile ResourceHacker -open %WIN_PKG_BUILT% -save %WIN_PKG_BUILT% -action addoverwrite -resource version-info.res - - name: Login to Azure + - name: Log in to Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -362,6 +382,10 @@ jobs: code-signing-client-secret, code-signing-cert-name" + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + - name: Install run: npm ci working-directory: ./ @@ -520,6 +544,9 @@ jobs: name: Check for failures if: always() runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write needs: - setup - cli @@ -534,11 +561,13 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Login to Azure - Prod Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure if: failure() + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -548,6 +577,10 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + if: failure() + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() diff --git a/.github/workflows/build-desktop-target.yml b/.github/workflows/build-desktop-target.yml index fa21b3fe5d9..31ac819a3e6 100644 --- a/.github/workflows/build-desktop-target.yml +++ b/.github/workflows/build-desktop-target.yml @@ -28,6 +28,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Build Desktop @@ -35,4 +37,7 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build-desktop.yml secrets: inherit + permissions: + contents: read + id-token: write diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index a022fe7fd0f..366d439fb45 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -147,10 +147,8 @@ jobs: - name: Check secrets id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT linux: @@ -404,6 +402,9 @@ jobs: runs-on: windows-2022 needs: - setup + permissions: + contents: read + id-token: write defaults: run: shell: pwsh @@ -438,11 +439,13 @@ jobs: choco --version rustup show - - name: Login to Azure + - name: Log in to Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -456,6 +459,10 @@ jobs: code-signing-client-secret, code-signing-cert-name" + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + - name: Install Node dependencies run: npm ci working-directory: ./ @@ -655,6 +662,9 @@ jobs: runs-on: macos-13 needs: - setup + permissions: + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -700,11 +710,21 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - name: Login to Azure + - name: Log in to Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + 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 + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-clients + secrets: "KEYCHAIN-PASSWORD" - name: Download Provisioning Profiles secrets if: ${{ needs.setup.outputs.has_secrets == 'true' }} @@ -747,10 +767,14 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain if: ${{ needs.setup.outputs.has_secrets == 'true' }} env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -850,6 +874,10 @@ jobs: if: ${{ needs.setup.outputs.has_secrets == 'true' }} uses: ./.github/workflows/build-browser.yml secrets: inherit + permissions: + contents: write + pull-requests: write + id-token: write macos-package-github: @@ -860,6 +888,9 @@ jobs: - browser-build - macos-build - setup + permissions: + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -905,10 +936,19 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + 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: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER" - name: Download Provisioning Profiles secrets env: @@ -949,9 +989,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -1055,12 +1098,12 @@ jobs: run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ secrets.APP_STORE_CONNECT_AUTH_KEY }} + ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} EOF - name: Build application (dist) env: - APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8 CSC_FOR_PULL_REQUEST: true @@ -1103,6 +1146,9 @@ jobs: - browser-build - macos-build - setup + permissions: + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -1148,10 +1194,19 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + 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: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER" - name: Retrieve Slack secret id: retrieve-slack-secret @@ -1199,9 +1254,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -1305,12 +1363,12 @@ jobs: run: | mkdir ~/private_keys cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8 - ${{ secrets.APP_STORE_CONNECT_AUTH_KEY }} + ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }} EOF - name: Build application for App Store env: - APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8 CSC_FOR_PULL_REQUEST: true @@ -1334,7 +1392,7 @@ jobs: cat << EOF > ~/secrets/appstoreconnect-fastlane.json { - "issuer_id": "${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}", + "issuer_id": "${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}", "key_id": "6TV9MKN3GP", "key": "$KEY_WITHOUT_NEWLINES" } @@ -1346,7 +1404,7 @@ jobs: github.event_name != 'pull_request_target' && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') env: - APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} + APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP BRANCH: ${{ github.ref }} run: | @@ -1396,6 +1454,10 @@ jobs: - windows - macos-package-github - macos-package-mas + permissions: + contents: write + pull-requests: write + id-token: write runs-on: ubuntu-22.04 steps: - name: Check out repo @@ -1403,10 +1465,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -1415,6 +1479,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Upload Sources uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 env: @@ -1442,6 +1509,9 @@ jobs: - macos-package-github - macos-package-mas - crowdin-push + permissions: + contents: read + id-token: write steps: - name: Check if any job failed if: | @@ -1450,11 +1520,13 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Login to Azure - Prod Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure if: failure() + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -1464,6 +1536,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() @@ -1471,3 +1546,4 @@ jobs: SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} with: status: ${{ job.status }} + diff --git a/.github/workflows/build-web-target.yml b/.github/workflows/build-web-target.yml index ca10e6d46f2..b1055885400 100644 --- a/.github/workflows/build-web-target.yml +++ b/.github/workflows/build-web-target.yml @@ -27,6 +27,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read run-workflow: name: Build Web @@ -34,4 +36,8 @@ jobs: if: ${{ github.event.pull_request.head.repo.full_name != github.repository }} uses: ./.github/workflows/build-web.yml secrets: inherit + permissions: + contents: read + id-token: write + security-events: write diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 745376b46d8..b4163d161cf 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -51,7 +51,8 @@ env: _AZ_REGISTRY: bitwardenprod.azurecr.io _GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }} -permissions: {} +permissions: + contents: read jobs: setup: @@ -80,10 +81,8 @@ jobs: - name: Check secrets id: check-secrets - env: - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} run: | - has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }} + has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT @@ -204,11 +203,13 @@ jobs: uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 ########## ACRs ########## - - name: Login to Prod Azure + - name: Log in to Azure if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log into Prod container registry if: ${{ needs.setup.outputs.has_secrets == 'true' }} @@ -328,11 +329,19 @@ jobs: - name: Log out of Docker run: docker logout $_AZ_REGISTRY + - name: Log out from Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + crowdin-push: name: Crowdin Push if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' needs: build-containers + permissions: + contents: write + pull-requests: write + id-token: write runs-on: ubuntu-24.04 steps: - name: Check out repo @@ -340,10 +349,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -352,6 +363,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Upload Sources uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 env: @@ -370,11 +384,15 @@ jobs: if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 needs: build-containers + permissions: + id-token: write steps: - - name: Login to Azure - CI Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve github PAT secrets id: retrieve-secret-pat @@ -383,6 +401,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger web vault deploy using GitHub Run ID uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: @@ -409,6 +430,8 @@ jobs: - build-containers - crowdin-push - trigger-web-vault-deploy + permissions: + id-token: write steps: - name: Check if any job failed if: | @@ -417,11 +440,13 @@ jobs: && contains(needs.*.result, 'failure') run: exit 1 - - name: Login to Azure - Prod Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main if: failure() with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -431,6 +456,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 if: failure() diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 78733bc5a8b..4ee39305f84 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -15,6 +15,8 @@ jobs: check-run: name: Check PR run uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + permissions: + contents: read chromatic: name: Chromatic @@ -23,6 +25,7 @@ jobs: permissions: contents: read pull-requests: write + id-token: write steps: - name: Check out repo @@ -30,13 +33,13 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - + - name: Get changed files id: get-changed-files-for-chromatic uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | - storyFiles: + storyFiles: - "apps/!(cli)/**" - "bitwarden_license/bit-web/src/app/**" - "libs/!(eslint)/**" @@ -74,11 +77,28 @@ jobs: if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' run: npm run build-storybook:ci + - 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: "CHROMATIC-PROJECT-TOKEN" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Publish to Chromatic uses: chromaui/action@e8cc4c31775280b175a3c440076c00d19a9014d7 # v11.28.2 with: token: ${{ secrets.GITHUB_TOKEN }} - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + projectToken: ${{ steps.get-kv-secrets.outputs.CHROMATIC-PROJECT-TOKEN }} storybookBuildDir: ./storybook-static exitOnceUploaded: true onlyChanged: true diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 2fc035ec038..0b891203855 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -10,6 +10,9 @@ jobs: crowdin-sync: name: Autosync runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write strategy: fail-fast: false matrix: @@ -21,22 +24,19 @@ jobs: - app_name: web crowdin_project_id: "308189" steps: - - name: Generate GH App token - uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 - id: app-token + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - token: ${{ steps.app-token.outputs.token }} - - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Retrieve secrets id: retrieve-secrets @@ -45,6 +45,21 @@ jobs: keyvault: "bitwarden-ci" secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Generate GH App token + uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ steps.app-token.outputs.token }} + - name: Download translations uses: bitwarden/gh-actions/crowdin@main env: diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 1cde8dd636a..3ffe18d1729 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -66,8 +66,9 @@ jobs: environment_url: ${{ steps.config.outputs.environment_url }} environment_name: ${{ steps.config.outputs.environment_name }} environment_artifact: ${{ steps.config.outputs.environment_artifact }} - azure_login_creds: ${{ steps.config.outputs.azure_login_creds }} - retrive_secrets_keyvault: ${{ steps.config.outputs.retrive_secrets_keyvault }} + azure_login_client_key_name: ${{ steps.config.outputs.azure_login_client_key_name }} + azure_login_subscription_id_key_name: ${{ steps.config.outputs.azure_login_subscription_id_key_name }} + retrieve_secrets_keyvault: ${{ steps.config.outputs.retrieve_secrets_keyvault }} sync_utility: ${{ steps.config.outputs.sync_utility }} sync_delete_destination_files: ${{ steps.config.outputs.sync_delete_destination_files }} slack_channel_name: ${{ steps.config.outputs.slack_channel_name }} @@ -81,40 +82,45 @@ jobs: case ${{ inputs.environment }} in "USQA") - echo "azure_login_creds=AZURE_KV_US_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT - echo "retrive_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USQA" >> $GITHUB_OUTPUT + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USQA" >> $GITHUB_OUTPUT + echo "retrieve_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT echo "environment_artifact=web-*-cloud-QA.zip" >> $GITHUB_OUTPUT echo "environment_name=Web Vault - US QA Cloud" >> $GITHUB_OUTPUT echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT ;; "EUQA") - echo "azure_login_creds=AZURE_KV_EU_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT - echo "retrive_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUQA" >> $GITHUB_OUTPUT + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUQA" >> $GITHUB_OUTPUT + echo "retrieve_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT echo "environment_artifact=web-*-cloud-euqa.zip" >> $GITHUB_OUTPUT echo "environment_name=Web Vault - EU QA Cloud" >> $GITHUB_OUTPUT echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT ;; "USPROD") - echo "azure_login_creds=AZURE_KV_US_PROD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT - echo "retrive_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USPROD" >> $GITHUB_OUTPUT + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USPROD" >> $GITHUB_OUTPUT + echo "retrieve_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT echo "environment_artifact=web-*-cloud-COMMERCIAL.zip" >> $GITHUB_OUTPUT echo "environment_name=Web Vault - US Production Cloud" >> $GITHUB_OUTPUT echo "environment_url=http://vault.bitwarden.com" >> $GITHUB_OUTPUT echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT ;; "EUPROD") - echo "azure_login_creds=AZURE_KV_EU_PRD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT - echo "retrive_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUPROD" >> $GITHUB_OUTPUT + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUPROD" >> $GITHUB_OUTPUT + echo "retrieve_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT echo "environment_artifact=web-*-cloud-euprd.zip" >> $GITHUB_OUTPUT echo "environment_name=Web Vault - EU Production Cloud" >> $GITHUB_OUTPUT echo "environment_url=http://vault.bitwarden.eu" >> $GITHUB_OUTPUT echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT ;; "USDEV") - echo "azure_login_creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT - echo "retrive_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT + echo "azure_login_client_key_name=AZURE_CLIENT_ID_USDEV" >> $GITHUB_OUTPUT + echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USDEV" >> $GITHUB_OUTPUT + echo "retrieve_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT echo "environment_artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT echo "environment_name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT @@ -180,6 +186,9 @@ jobs: name: Check if Web artifact is present runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + id-token: write env: _ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }} outputs: @@ -209,11 +218,13 @@ jobs: branch: ${{ inputs.branch-or-tag }} artifacts: ${{ env._ENVIRONMENT_ARTIFACT }} - - name: Login to Azure + - name: Log in to Azure if: ${{ steps.download-latest-artifacts.outcome == 'failure' }} - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets for Build trigger if: ${{ steps.download-latest-artifacts.outcome == 'failure' }} @@ -223,6 +234,10 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + if: ${{ steps.download-latest-artifacts.outcome == 'failure' }} + uses: bitwarden/gh-actions/azure-logout@main + - name: 'Trigger build web for missing branch/tag ${{ inputs.branch-or-tag }}' if: ${{ steps.download-latest-artifacts.outcome == 'failure' }} uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 @@ -277,7 +292,9 @@ jobs: event: 'start' commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }} url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }} - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} update-summary: name: Display commit @@ -302,6 +319,9 @@ jobs: _ENVIRONMENT_URL: ${{ needs.setup.outputs.environment_url }} _ENVIRONMENT_NAME: ${{ needs.setup.outputs.environment_name }} _ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }} + permissions: + id-token: write + deployments: write steps: - name: Create GitHub deployment uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 @@ -309,23 +329,25 @@ jobs: with: token: '${{ secrets.GITHUB_TOKEN }}' initial-status: 'in_progress' - environment_url: ${{ env._ENVIRONMENT_URL }} + environment-url: ${{ env._ENVIRONMENT_URL }} environment: ${{ env._ENVIRONMENT_NAME }} task: 'deploy' description: 'Deployment from branch/tag: ${{ inputs.branch-or-tag }}' ref: ${{ needs.artifact-check.outputs.artifact_build_commit }} - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets[needs.setup.outputs.azure_login_creds] }} + subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }} - name: Retrieve Storage Account connection string for az sync if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }} id: retrieve-secrets-az-sync uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }} + keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }} secrets: "sa-bitwarden-web-vault-dev-key-temp" - name: Retrieve Storage Account name and SPN credentials for azcopy @@ -333,9 +355,12 @@ jobs: id: retrieve-secrets-azcopy uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }} + keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }} secrets: "sa-bitwarden-web-vault-name,sp-bitwarden-web-vault-password,sp-bitwarden-web-vault-appid,sp-bitwarden-web-vault-tenant" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}' if: ${{ inputs.build-web-run-id }} uses: bitwarden/gh-actions/download-artifacts@main @@ -397,7 +422,7 @@ jobs: uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 with: token: '${{ secrets.GITHUB_TOKEN }}' - environment_url: ${{ env._ENVIRONMENT_URL }} + environment-url: ${{ env._ENVIRONMENT_URL }} state: 'success' deployment-id: ${{ steps.deployment.outputs.deployment_id }} @@ -406,7 +431,7 @@ jobs: uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 with: token: '${{ secrets.GITHUB_TOKEN }}' - environment_url: ${{ env._ENVIRONMENT_URL }} + environment-url: ${{ env._ENVIRONMENT_URL }} state: 'failure' deployment-id: ${{ steps.deployment.outputs.deployment_id }} @@ -419,6 +444,8 @@ jobs: - notify-start - azure-deploy - artifact-check + permissions: + id-token: write steps: - name: Notify Slack with result uses: bitwarden/gh-actions/report-deployment-status-to-slack@main @@ -431,4 +458,6 @@ jobs: url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }} commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }} update-ts: ${{ needs.notify-start.outputs.ts }} - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index adb5950e3a0..38a3ef59ea7 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -5,12 +5,14 @@ on: types: [opened, synchronize] paths: - '**/crowdin.yml' -permissions: {} jobs: lint-crowdin-config: name: Lint Crowdin Config ${{ matrix.app.name }} runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write strategy: matrix: app: [ @@ -22,17 +24,25 @@ jobs: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - fetch-depth: 1 - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + fetch-depth: 1 + + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + - name: Retrieve secrets id: retrieve-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Lint ${{ matrix.app.name }} config uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1 env: @@ -42,4 +52,4 @@ jobs: with: dryrun_action: true command: 'config lint' - command_args: '--verbose -c ${{ matrix.app.config_path }}' \ No newline at end of file + command_args: '--verbose -c ${{ matrix.app.config_path }}' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index d758e6f11c9..efb0f541d70 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -48,6 +48,10 @@ jobs: defaults: run: working-directory: . + permissions: + contents: read + deployments: write + steps: - name: Branch check if: ${{ inputs.publish_type != 'Dry Run' }} @@ -86,6 +90,10 @@ jobs: name: Deploy Snap runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + packages: read + id-token: write if: inputs.snap_publish env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} @@ -93,10 +101,12 @@ jobs: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -105,6 +115,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Install Snap uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 @@ -123,6 +136,10 @@ jobs: name: Deploy Choco runs-on: windows-2022 needs: setup + permissions: + contents: read + packages: read + id-token: write if: inputs.choco_publish env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} @@ -130,10 +147,12 @@ jobs: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -142,6 +161,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Setup Chocolatey run: choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/ env: @@ -163,6 +185,10 @@ jobs: name: Publish NPM runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + packages: read + id-token: write if: inputs.npm_publish env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} @@ -170,10 +196,12 @@ jobs: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -182,6 +210,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "npm-api-key" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Download and set up artifact run: | mkdir -p build @@ -210,6 +241,10 @@ jobs: - npm - snap - choco + permissions: + contents: read + deployments: write + if: ${{ always() && inputs.publish_type != 'Dry Run' }} steps: - name: Check if any job failed diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index ae631165db9..aafc4d25ed4 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -42,6 +42,9 @@ jobs: release_channel: ${{ steps.release_channel.outputs.channel }} tag_name: ${{ steps.version.outputs.tag_name }} deployment_id: ${{ steps.deployment.outputs.deployment_id }} + permissions: + contents: read + deployments: write steps: - name: Branch check if: ${{ inputs.publish_type != 'Dry Run' }} @@ -106,14 +109,21 @@ jobs: name: Electron blob publish runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + packages: read + id-token: write + deployments: write env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} _RELEASE_TAG: ${{ needs.setup.outputs.tag_name }} steps: - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -124,6 +134,9 @@ jobs: aws-electron-access-key, aws-electron-bucket-name" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Create artifacts directory run: mkdir -p apps/desktop/artifacts @@ -176,6 +189,9 @@ jobs: name: Deploy Snap runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read + id-token: write if: inputs.snap_publish env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} @@ -184,10 +200,12 @@ jobs: - name: Checkout Repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -196,6 +214,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Install Snap uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 @@ -220,6 +241,9 @@ jobs: name: Deploy Choco runs-on: windows-2022 needs: setup + permissions: + contents: read + id-token: write if: inputs.choco_publish env: _PKG_VERSION: ${{ needs.setup.outputs.release_version }} @@ -233,10 +257,12 @@ jobs: dotnet --version dotnet nuget --version - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -245,6 +271,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Setup Chocolatey run: choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/ env: @@ -271,6 +300,9 @@ jobs: - electron-blob - snap - choco + permissions: + contents: read + deployments: write if: ${{ always() && inputs.publish_type != 'Dry Run' }} steps: - name: Check if any job failed diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 69b29086d36..a6f0f1be066 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -24,6 +24,8 @@ jobs: outputs: release_version: ${{ steps.version.outputs.version }} tag_version: ${{ steps.version.outputs.tag }} + permissions: + contents: read steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -52,6 +54,10 @@ jobs: name: Release self-host docker runs-on: ubuntu-22.04 needs: setup + permissions: + id-token: write + contents: read + deployments: write env: _BRANCH_NAME: ${{ github.ref_name }} _RELEASE_VERSION: ${{ needs.setup.outputs.release_version }} @@ -69,10 +75,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ########## ACR ########## - - name: Login to Azure - PROD Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Login to Azure ACR run: az acr login -n bitwardenprod @@ -121,6 +129,9 @@ jobs: docker push $_AZ_REGISTRY/web-sh:latest fi + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Update deployment status to Success if: ${{ inputs.publish_type != 'Dry Run' && success() }} uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 @@ -147,11 +158,15 @@ jobs: runs-on: ubuntu-22.04 needs: - setup + permissions: + id-token: write steps: - - name: Log in to Azure - CI subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve GitHub PAT secrets id: retrieve-secret-pat @@ -160,6 +175,9 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Trigger self-host build uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index a5e374395d8..e3eb9090cb7 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -15,6 +15,8 @@ jobs: setup: name: Setup runs-on: ubuntu-22.04 + permissions: + contents: write outputs: release_version: ${{ steps.version.outputs.version }} release_channel: ${{ steps.release_channel.outputs.channel }} @@ -115,6 +117,8 @@ jobs: name: Linux Build runs-on: ubuntu-22.04 needs: setup + permissions: + contents: read env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -204,6 +208,9 @@ jobs: name: Windows Build runs-on: windows-2022 needs: setup + permissions: + contents: read + id-token: write defaults: run: shell: pwsh @@ -237,10 +244,12 @@ jobs: npm --version choco --version - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -253,6 +262,9 @@ jobs: code-signing-client-secret, code-signing-cert-name" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Install Node dependencies run: npm ci working-directory: ./ @@ -394,6 +406,9 @@ jobs: name: MacOS Build runs-on: macos-13 needs: setup + permissions: + contents: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -438,6 +453,20 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension + - 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: "KEYCHAIN-PASSWORD" + - name: Download Provisioning Profiles secrets env: ACCOUNT_NAME: bitwardenci @@ -472,9 +501,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -528,6 +560,10 @@ jobs: needs: - setup - macos-build + permissions: + contents: read + packages: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -572,10 +608,19 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + 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: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - name: Download Provisioning Profiles secrets env: @@ -611,9 +656,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -702,8 +750,8 @@ jobs: - name: Build application (dist) env: - APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} + APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} run: npm run pack:mac - name: Upload .zip artifact @@ -741,6 +789,10 @@ jobs: needs: - setup - macos-build + permissions: + contents: read + packages: read + id-token: write env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -785,6 +837,20 @@ jobs: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension + - 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: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" + - name: Download Provisioning Profiles secrets env: ACCOUNT_NAME: bitwardenci @@ -819,9 +885,12 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Set up keychain env: - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} run: | security create-keychain -p $KEYCHAIN_PASSWORD build.keychain security default-keychain -s build.keychain @@ -911,8 +980,8 @@ jobs: - name: Build application for App Store run: npm run pack:mac:mas env: - APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} + APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - name: Upload .pkg artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 @@ -931,6 +1000,10 @@ jobs: - macos-build - macos-package-github - macos-package-mas + permissions: + contents: read + id-token: write + deployments: write steps: - name: Create GitHub deployment uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 @@ -942,10 +1015,12 @@ jobs: description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}' task: release - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -956,6 +1031,9 @@ jobs: aws-electron-access-key, aws-electron-bucket-name" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Download all artifacts uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: @@ -1008,6 +1086,8 @@ jobs: - macos-package-github - macos-package-mas - release + permissions: + contents: write steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index d91e0a12afd..ecb8e448a8a 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -36,7 +36,9 @@ on: description: "New version override (leave blank for automatic calculation, example: '2024.1.0')" required: false type: string + permissions: {} + jobs: setup: name: Setup @@ -56,6 +58,7 @@ jobs: fi echo "branch=$BRANCH" >> $GITHUB_OUTPUT + bump_version: name: Bump Version if: ${{ always() }} @@ -66,6 +69,9 @@ jobs: version_cli: ${{ steps.set-final-version-output.outputs.version_cli }} version_desktop: ${{ steps.set-final-version-output.outputs.version_desktop }} version_web: ${{ steps.set-final-version-output.outputs.version_web }} + permissions: + id-token: write + steps: - name: Validate version input format if: ${{ inputs.version_number_override != '' }} @@ -73,12 +79,29 @@ jobs: with: version: ${{ inputs.version_number_override }} + - 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-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -400,6 +423,7 @@ jobs: - name: Push changes if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: git push + cut_branch: name: Cut branch if: ${{ needs.setup.outputs.branch == 'rc' }} @@ -407,13 +431,33 @@ jobs: - setup - bump_version runs-on: ubuntu-24.04 + permissions: + id-token: write + steps: + - 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-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -435,4 +479,4 @@ jobs: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME \ No newline at end of file + git push --quiet --set-upstream origin $BRANCH_NAME diff --git a/.github/workflows/retrieve-current-desktop-rollout.yml b/.github/workflows/retrieve-current-desktop-rollout.yml index 2ab3072f566..c45453ed9d0 100644 --- a/.github/workflows/retrieve-current-desktop-rollout.yml +++ b/.github/workflows/retrieve-current-desktop-rollout.yml @@ -11,11 +11,15 @@ jobs: rollout: name: Retrieve Rollout Percentage runs-on: ubuntu-22.04 + permissions: + id-token: write steps: - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -26,6 +30,9 @@ jobs: aws-electron-access-key, aws-electron-bucket-name" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Download channel update info files from S3 env: AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index 4ec3af3be97..4adf81100bd 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -18,11 +18,15 @@ jobs: rollout: name: Update Rollout Percentage runs-on: ubuntu-22.04 + permissions: + id-token: write steps: - - name: Login to Azure - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Retrieve secrets id: retrieve-secrets @@ -33,6 +37,9 @@ jobs: aws-electron-access-key, aws-electron-bucket-name" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Download channel update info files from S3 env: AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index e8bd1dde246..3cb5646886a 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -9,13 +9,33 @@ jobs: bump-version: name: Bump Desktop Version runs-on: ubuntu-24.04 + permissions: + id-token: write + contents: write steps: + - 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-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 id: app-token with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 From 1f20bcecf0b0154ed45c889625e734069113a643 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:06:02 -0500 Subject: [PATCH 29/54] Hide bank account for premium and when non-premium selects non-US country (#15707) --- .../change-payment-method-dialog.component.ts | 6 +++- .../enter-payment-method.component.ts | 31 ++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index efd0055fb95..ff5156ba636 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -28,7 +28,11 @@ type DialogResult = {{ "changePaymentMethod" | i18n }} </span> <div bitDialogContent> - <app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true"> + <app-enter-payment-method + [group]="formGroup" + [showBankAccount]="dialogParams.owner.type !== 'account'" + [includeBillingAddress]="true" + > </app-enter-payment-method> </div> <ng-container bitDialogFooter> diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 4f5b2e3b15c..b73c3297e9e 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs"; +import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -48,7 +48,7 @@ type PaymentMethodFormGroup = FormGroup<{ {{ "creditCard" | i18n }} </bit-label> </bit-radio-button> - @if (showBankAccount) { + @if (showBankAccount$ | async) { <bit-radio-button id="bank-payment-method" [value]="'bankAccount'"> <bit-label> <i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i> @@ -226,20 +226,12 @@ type PaymentMethodFormGroup = FormGroup<{ export class EnterPaymentMethodComponent implements OnInit { @Input({ required: true }) group!: PaymentMethodFormGroup; - private showBankAccountSubject = new BehaviorSubject<boolean>(true); - showBankAccount$ = this.showBankAccountSubject.asObservable(); - @Input() - set showBankAccount(value: boolean) { - this.showBankAccountSubject.next(value); - } - get showBankAccount(): boolean { - return this.showBankAccountSubject.value; - } - - @Input() showPayPal: boolean = true; - @Input() showAccountCredit: boolean = false; - @Input() includeBillingAddress: boolean = false; + @Input() private showBankAccount = true; + @Input() showPayPal = true; + @Input() showAccountCredit = false; + @Input() includeBillingAddress = false; + protected showBankAccount$!: Observable<boolean>; protected selectableCountries = selectableCountries; private destroy$ = new Subject<void>(); @@ -267,7 +259,16 @@ export class EnterPaymentMethodComponent implements OnInit { } if (!this.includeBillingAddress) { + this.showBankAccount$ = of(this.showBankAccount); this.group.controls.billingAddress.disable(); + } else { + this.group.controls.billingAddress.patchValue({ + country: "US", + }); + this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe( + startWith(this.group.controls.billingAddress.controls.country.value), + map((country) => this.showBankAccount && country === "US"), + ); } this.group.controls.type.valueChanges From 77940116e6818ef5eb4eb8247aab6e75574bf0a3 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum <robyntmaccallum@gmail.com> Date: Mon, 21 Jul 2025 16:15:39 -0400 Subject: [PATCH 30/54] DDG integration files modified workflow (#15665) * Create alert-ddg-files-modified.yml * Update alert-ddg-files-modified.yml * Add encrypted-message-handler.service to alert-ddg-files-modified.yml * Pin action versions * Add permissions * Update alert-ddg-files-modified.yml * Update alert-ddg-files-modified.yml * Add parameter to get list of files changed * Wording update * Update CODEOWNERS * Make branch target main --- .github/CODEOWNERS | 2 + .../workflows/alert-ddg-files-modified.yml | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .github/workflows/alert-ddg-files-modified.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef2e26916e5..7d7fec2a5ea 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -138,6 +138,8 @@ apps/desktop/desktop_native/autotype @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev +apps/desktop/src/services/encrypted-message-handler.service.ts @bitwarden/team-autofill-dev +.github/workflows/alert-ddg-files-modified.yml @bitwarden/team-autofill-dev # SSH Agent apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-dev @bitwarden/wg-ssh-keys diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml new file mode 100644 index 00000000000..61bb7f1e8af --- /dev/null +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -0,0 +1,50 @@ +name: DDG File Change Monitor + +on: + pull_request: + branches: [ main ] + types: [ opened, synchronize ] + +jobs: + check-files: + name: Check files + runs-on: ubuntu-22.04 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + list-files: shell + filters: | + monitored: + - 'apps/desktop/native-messaging-test-runner/**' + - 'apps/desktop/src/services/duckduckgo-message-handler.service.ts' + - 'apps/desktop/src/services/encrypted-message-handler.service.ts' + + - name: Comment on PR if monitored files changed + if: steps.changed-files.outputs.monitored == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const changedFiles = `${{ steps.changed-files.outputs.monitored_files }}`.split(' ').filter(file => file.trim() !== ''); + + const message = `⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:** + + ${changedFiles.map(file => `- \`${file}\``).join('\n')} + + Please run the DuckDuckGo native messaging test runner from this branch using [these instructions](https://contributing.bitwarden.com/getting-started/clients/desktop/native-messaging-test-runner) and ensure it functions properly.`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: message + }); From 81ee26733e6040a8d32afda67b5c3db3d8d094e0 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:17:39 -0400 Subject: [PATCH 31/54] [BRE-831] Fixing permissions (#15713) --- .github/workflows/deploy-web.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 3ffe18d1729..e21f7ae1e79 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -277,6 +277,8 @@ jobs: - artifact-check runs-on: ubuntu-22.04 if: ${{ always() && ( contains( inputs.environment , 'QA' ) || contains( inputs.environment , 'DEV' ) ) }} + permissions: + id-token: write outputs: channel_id: ${{ steps.slack-message.outputs.channel_id }} ts: ${{ steps.slack-message.outputs.ts }} From 391f540d1f488354002090823c3054e960b28a15 Mon Sep 17 00:00:00 2001 From: Shane Melton <smelton@bitwarden.com> Date: Mon, 21 Jul 2025 23:27:01 -0700 Subject: [PATCH 32/54] [PM-22136] Implement SDK cipher encryption (#15337) * [PM-22136] Update sdk cipher view map to support uknown uuid type * [PM-22136] Add key to CipherView for copying to SdkCipherView for encryption * [PM-22136] Add fromSdk* helpers to Cipher domain objects * [PM-22136] Add toSdk* helpers to Cipher View objects * [PM-22136] Add encrypt() to cipher encryption service * [PM-22136] Add feature flag * [PM-22136] Use new SDK encrypt method when feature flag is enabled * [PM-22136] Filter out null/empty URIs * [PM-22136] Change default value for cipher view arrays to []. See ADR-0014. * [PM-22136] Keep encrypted key value on attachment so that it is passed to the SDK * [PM-22136] Keep encrypted key value on CipherView so that it is passed to the SDK during encryption * [PM-22136] Update failing attachment test * [PM-22136] Update failing importer tests due to new default value for arrays * [PM-22136] Update CipherView.fromJson to handle the prototype of EncString for the cipher key * [PM-22136] Add tickets for followup work * [PM-22136] Use new set_fido2_credentials SDK method instead * [PM-22136] Fix missing prototype when decrypting Fido2Credentials * [PM-22136] Fix test after sdk change * [PM-22136] Update @bitwarden/sdk-internal version * [PM-22136] Fix some strict typing errors * [PM-23348] Migrate move cipher to org to SDK (#15567) * [PM-23348] Add moveToOrganization method to cipher-encryption.service.ts * [PM-23348] Use cipherEncryptionService.moveToOrganization in cipherService shareWithServer and shareManyWithServer methods * [PM-23348] Update cipherFormService to use the shareWithServer() method instead of encrypt() * [PM-23348] Fix typo * [PM-23348] Add missing docs * [PM-22136] Fix EncString import after merge with main --- libs/common/src/enums/feature-flag.enum.ts | 2 + .../abstractions/cipher-encryption.service.ts | 25 +- .../src/vault/abstractions/cipher.service.ts | 10 + .../models/api/cipher-permissions.api.ts | 9 +- .../src/vault/models/data/local.data.ts | 41 +++ .../vault/models/domain/attachment.spec.ts | 1 + .../src/vault/models/domain/attachment.ts | 21 ++ libs/common/src/vault/models/domain/card.ts | 20 ++ .../src/vault/models/domain/cipher.spec.ts | 188 +++++++++++++- libs/common/src/vault/models/domain/cipher.ts | 60 ++++- .../vault/models/domain/fido2-credential.ts | 28 ++ .../src/vault/models/domain/field.spec.ts | 39 ++- libs/common/src/vault/models/domain/field.ts | 18 ++ .../src/vault/models/domain/identity.ts | 32 +++ .../src/vault/models/domain/login-uri.ts | 13 + libs/common/src/vault/models/domain/login.ts | 27 ++ .../src/vault/models/domain/password.ts | 16 ++ .../src/vault/models/domain/secure-note.ts | 15 ++ .../common/src/vault/models/domain/ssh-key.ts | 17 ++ .../common/src/vault/models/view/card.view.ts | 10 +- .../src/vault/models/view/cipher.view.spec.ts | 94 ++++++- .../src/vault/models/view/cipher.view.ts | 116 +++++++-- .../models/view/fido2-credential.view.ts | 23 +- .../src/vault/models/view/field.view.ts | 14 +- .../src/vault/models/view/identity.view.ts | 10 +- .../src/vault/models/view/login-uri.view.ts | 9 + .../src/vault/models/view/login.view.ts | 22 +- .../models/view/password-history.view.spec.ts | 13 + .../models/view/password-history.view.ts | 10 + .../src/vault/models/view/secure-note.view.ts | 10 +- .../src/vault/models/view/ssh-key.view.ts | 11 + .../src/vault/services/cipher.service.spec.ts | 167 +++++++++++- .../src/vault/services/cipher.service.ts | 122 +++++++-- .../default-cipher-encryption.service.spec.ts | 245 ++++++++++++++++-- .../default-cipher-encryption.service.ts | 106 +++++++- libs/importer/src/importers/base-importer.ts | 6 - .../keeper/keeper-csv-importer.spec.ts | 2 +- .../keeper/keeper-json-importer.spec.ts | 2 +- .../services/default-cipher-form.service.ts | 35 ++- .../cipher-view/cipher-view.component.html | 4 +- .../password-history-view.component.spec.ts | 11 +- package-lock.json | 8 +- package.json | 2 +- 43 files changed, 1485 insertions(+), 149 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1af2ab1f0a9..8e9dc6fd35e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -54,6 +54,7 @@ export enum FeatureFlag { PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", + PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", @@ -103,6 +104,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, + [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, /* Auth */ [FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE, diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 6b2a8e8943e..067c63b2110 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -1,6 +1,7 @@ +import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherListView } from "@bitwarden/sdk-internal"; -import { UserId } from "../../types/guid"; +import { UserId, OrganizationId } from "../../types/guid"; import { Cipher } from "../models/domain/cipher"; import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; @@ -9,6 +10,28 @@ import { CipherView } from "../models/view/cipher.view"; * Service responsible for encrypting and decrypting ciphers. */ export abstract class CipherEncryptionService { + /** + * Encrypts a cipher using the SDK for the given userId. + * @param model The cipher view to encrypt + * @param userId The user ID to initialize the SDK client with + * + * @returns A promise that resolves to the encryption context, or undefined if encryption fails + */ + abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>; + + /** + * Move the cipher to the specified organization by re-encrypting its keys with the organization's key. + * The cipher.organizationId will be updated to the new organizationId. + * @param model The cipher view to move to the organization + * @param organizationId The ID of the organization to move the cipher to + * @param userId The user ID to initialize the SDK client with + */ + abstract moveToOrganization( + model: CipherView, + organizationId: OrganizationId, + userId: UserId, + ): Promise<EncryptionContext | undefined>; + /** * Decrypts a cipher using the SDK for the given userId. * diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index d1d686a66af..2f186369463 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -120,11 +120,21 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe orgAdmin?: boolean, isNotClone?: boolean, ): Promise<Cipher>; + + /** + * Move a cipher to an organization by re-encrypting its keys with the organization's key. + * @param cipher The cipher to move + * @param organizationId The Id of the organization to move the cipher to + * @param collectionIds The collection Ids to assign the cipher to in the organization + * @param userId The Id of the user performing the operation + * @param originalCipher Optional original cipher that will be used to compare/update password history + */ abstract shareWithServer( cipher: CipherView, organizationId: string, collectionIds: string[], userId: UserId, + originalCipher?: Cipher, ): Promise<Cipher>; abstract shareManyWithServer( ciphers: CipherView[], diff --git a/libs/common/src/vault/models/api/cipher-permissions.api.ts b/libs/common/src/vault/models/api/cipher-permissions.api.ts index b7341d39b1d..f9b62c4fc8d 100644 --- a/libs/common/src/vault/models/api/cipher-permissions.api.ts +++ b/libs/common/src/vault/models/api/cipher-permissions.api.ts @@ -4,7 +4,7 @@ import { CipherPermissions as SdkCipherPermissions } from "@bitwarden/sdk-intern import { BaseResponse } from "../../../models/response/base.response"; -export class CipherPermissionsApi extends BaseResponse { +export class CipherPermissionsApi extends BaseResponse implements SdkCipherPermissions { delete: boolean = false; restore: boolean = false; @@ -35,4 +35,11 @@ export class CipherPermissionsApi extends BaseResponse { return permissions; } + + /** + * Converts the CipherPermissionsApi to an SdkCipherPermissions + */ + toSdkCipherPermissions(): SdkCipherPermissions { + return this; + } } diff --git a/libs/common/src/vault/models/data/local.data.ts b/libs/common/src/vault/models/data/local.data.ts index 9ba820a58a2..50a24feba6f 100644 --- a/libs/common/src/vault/models/data/local.data.ts +++ b/libs/common/src/vault/models/data/local.data.ts @@ -1,4 +1,45 @@ +import { + LocalDataView as SdkLocalDataView, + LocalData as SdkLocalData, +} from "@bitwarden/sdk-internal"; + export type LocalData = { lastUsedDate?: number; lastLaunched?: number; }; + +/** + * Convert the SdkLocalDataView to LocalData + * @param localData + */ +export function fromSdkLocalData( + localData: SdkLocalDataView | SdkLocalData | undefined, +): LocalData | undefined { + if (localData == null) { + return undefined; + } + return { + lastUsedDate: localData.lastUsedDate ? new Date(localData.lastUsedDate).getTime() : undefined, + lastLaunched: localData.lastLaunched ? new Date(localData.lastLaunched).getTime() : undefined, + }; +} + +/** + * Convert the LocalData to SdkLocalData + * @param localData + */ +export function toSdkLocalData( + localData: LocalData | undefined, +): (SdkLocalDataView & SdkLocalData) | undefined { + if (localData == null) { + return undefined; + } + return { + lastUsedDate: localData.lastUsedDate + ? new Date(localData.lastUsedDate).toISOString() + : undefined, + lastLaunched: localData.lastLaunched + ? new Date(localData.lastLaunched).toISOString() + : undefined, + }; +} diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index d2b536b0590..2ea2c3d9a1d 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -93,6 +93,7 @@ describe("Attachment", () => { sizeName: "1.1 KB", fileName: "fileName", key: expect.any(SymmetricCryptoKey), + encryptedKey: attachment.key, }); }); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index abfebffb2e6..638f354c4b8 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -56,6 +56,7 @@ export class Attachment extends Domain { if (this.key != null) { view.key = await this.decryptAttachmentKey(orgId, encKey); + view.encryptedKey = this.key; // Keep the encrypted key for the view } return view; @@ -131,4 +132,24 @@ export class Attachment extends Domain { key: this.key?.toJSON(), }; } + + /** + * Maps an SDK Attachment object to an Attachment + * @param obj - The SDK attachment object + */ + static fromSdkAttachment(obj: SdkAttachment): Attachment | undefined { + if (!obj) { + return undefined; + } + + const attachment = new Attachment(); + attachment.id = obj.id; + attachment.url = obj.url; + attachment.size = obj.size; + attachment.sizeName = obj.sizeName; + attachment.fileName = EncString.fromJSON(obj.fileName); + attachment.key = EncString.fromJSON(obj.key); + + return attachment; + } } diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index c78f9dfb719..688053ae93c 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -103,4 +103,24 @@ export class Card extends Domain { code: this.code?.toJSON(), }; } + + /** + * Maps an SDK Card object to a Card + * @param obj - The SDK Card object + */ + static fromSdkCard(obj: SdkCard): Card | undefined { + if (obj == null) { + return undefined; + } + + const card = new Card(); + card.cardholderName = EncString.fromJSON(obj.cardholderName); + card.brand = EncString.fromJSON(obj.brand); + card.number = EncString.fromJSON(obj.number); + card.expMonth = EncString.fromJSON(obj.expMonth); + card.expYear = EncString.fromJSON(obj.expYear); + card.code = EncString.fromJSON(obj.code); + + return card; + } } diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 3ea8916a10b..60fff8b510e 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -10,6 +10,7 @@ import { UriMatchType, CipherRepromptType as SdkCipherRepromptType, LoginLinkedIdType, + Cipher as SdkCipher, } from "@bitwarden/sdk-internal"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; @@ -206,7 +207,7 @@ describe("Cipher DTO", () => { it("Convert", () => { const cipher = new Cipher(cipherData); - expect(cipher).toEqual({ + expect(cipher).toMatchObject({ initializerKey: InitializerKey.Cipher, id: "id", organizationId: "orgId", @@ -339,9 +340,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, login: loginView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -462,9 +463,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, secureNote: { type: 0 }, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -603,9 +604,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, card: cardView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -768,9 +769,9 @@ describe("Cipher DTO", () => { edit: true, viewPassword: true, identity: identityView, - attachments: null, - fields: null, - passwordHistory: null, + attachments: [], + fields: [], + passwordHistory: [], collectionIds: undefined, revisionDate: new Date("2022-01-31T12:00:00.000Z"), creationDate: new Date("2022-01-01T12:00:00.000Z"), @@ -1001,6 +1002,167 @@ describe("Cipher DTO", () => { revisionDate: "2022-01-31T12:00:00.000Z", }); }); + + it("should map from SDK Cipher", () => { + jest.restoreAllMocks(); + const sdkCipher: SdkCipher = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + collectionIds: [], + key: "EncryptedString", + name: "EncryptedString", + notes: "EncryptedString", + type: SdkCipherType.Login, + login: { + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchType.Domain, + }, + ], + totp: "EncryptedString", + autofillOnPageLoad: false, + fido2Credentials: undefined, + }, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + favorite: false, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: true, + edit: true, + permissions: new CipherPermissionsApi(), + viewPassword: true, + localData: { + lastUsedDate: "2025-04-15T12:00:00.000Z", + lastLaunched: "2025-04-15T12:00:00.000Z", + }, + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Password, + }, + ], + passwordHistory: [ + { + password: "EncryptedString", + lastUsedDate: "2022-01-31T12:00:00.000Z", + }, + ], + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: undefined, + revisionDate: "2022-01-31T12:00:00.000Z", + }; + + const lastUsedDate = new Date("2025-04-15T12:00:00.000Z").getTime(); + const lastLaunched = new Date("2025-04-15T12:00:00.000Z").getTime(); + + const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + permissions: new CipherPermissionsApi(), + collectionIds: [], + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + reprompt: CipherRepromptType.None, + key: "EncryptedString", + login: { + uris: [ + { + uri: "EncryptedString", + uriChecksum: "EncryptedString", + match: UriMatchStrategy.Domain, + }, + ], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [ + { password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }, + ], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Username, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Linked, + linkedId: LoginLinkedId.Password, + }, + ], + }; + const expectedCipher = new Cipher(cipherData, { lastUsedDate, lastLaunched }); + + const cipher = Cipher.fromSdkCipher(sdkCipher); + + expect(cipher).toEqual(expectedCipher); + }); }); }); diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 2f64fb82726..2a13cb06d71 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; +import { uuidToString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Cipher as SdkCipher } from "@bitwarden/sdk-internal"; import { EncString } from "../../../key-management/crypto/models/enc-string"; @@ -14,7 +15,7 @@ import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; import { CipherData } from "../data/cipher.data"; -import { LocalData } from "../data/local.data"; +import { LocalData, fromSdkLocalData, toSdkLocalData } from "../data/local.data"; import { AttachmentView } from "../view/attachment.view"; import { CipherView } from "../view/cipher.view"; import { FieldView } from "../view/field.view"; @@ -361,16 +362,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> { } : undefined, viewPassword: this.viewPassword ?? true, - localData: this.localData - ? { - lastUsedDate: this.localData.lastUsedDate - ? new Date(this.localData.lastUsedDate).toISOString() - : undefined, - lastLaunched: this.localData.lastLaunched - ? new Date(this.localData.lastLaunched).toISOString() - : undefined, - } - : undefined, + localData: toSdkLocalData(this.localData), attachments: this.attachments?.map((a) => a.toSdkAttachment()), fields: this.fields?.map((f) => f.toSdkField()), passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistory()), @@ -408,4 +400,50 @@ export class Cipher extends Domain implements Decryptable<CipherView> { return sdkCipher; } + + /** + * Maps an SDK Cipher object to a Cipher + * @param sdkCipher - The SDK Cipher object + */ + static fromSdkCipher(sdkCipher: SdkCipher | null): Cipher | undefined { + if (sdkCipher == null) { + return undefined; + } + + const cipher = new Cipher(); + + cipher.id = sdkCipher.id ? uuidToString(sdkCipher.id) : undefined; + cipher.organizationId = sdkCipher.organizationId + ? uuidToString(sdkCipher.organizationId) + : undefined; + cipher.folderId = sdkCipher.folderId ? uuidToString(sdkCipher.folderId) : undefined; + cipher.collectionIds = sdkCipher.collectionIds ? sdkCipher.collectionIds.map(uuidToString) : []; + cipher.key = EncString.fromJSON(sdkCipher.key); + cipher.name = EncString.fromJSON(sdkCipher.name); + cipher.notes = EncString.fromJSON(sdkCipher.notes); + cipher.type = sdkCipher.type; + cipher.favorite = sdkCipher.favorite; + cipher.organizationUseTotp = sdkCipher.organizationUseTotp; + cipher.edit = sdkCipher.edit; + cipher.permissions = CipherPermissionsApi.fromSdkCipherPermissions(sdkCipher.permissions); + cipher.viewPassword = sdkCipher.viewPassword; + cipher.localData = fromSdkLocalData(sdkCipher.localData); + cipher.attachments = sdkCipher.attachments?.map((a) => Attachment.fromSdkAttachment(a)) ?? []; + cipher.fields = sdkCipher.fields?.map((f) => Field.fromSdkField(f)) ?? []; + cipher.passwordHistory = + sdkCipher.passwordHistory?.map((ph) => Password.fromSdkPasswordHistory(ph)) ?? []; + cipher.creationDate = new Date(sdkCipher.creationDate); + cipher.revisionDate = new Date(sdkCipher.revisionDate); + cipher.deletedDate = sdkCipher.deletedDate ? new Date(sdkCipher.deletedDate) : null; + cipher.reprompt = sdkCipher.reprompt; + + // Cipher type specific properties + cipher.login = Login.fromSdkLogin(sdkCipher.login); + cipher.secureNote = SecureNote.fromSdkSecureNote(sdkCipher.secureNote); + cipher.card = Card.fromSdkCard(sdkCipher.card); + cipher.identity = Identity.fromSdkIdentity(sdkCipher.identity); + cipher.sshKey = SshKey.fromSdkSshKey(sdkCipher.sshKey); + + return cipher; + } } diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index 508f8a6d5fb..5dbf55b44fc 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -173,4 +173,32 @@ export class Fido2Credential extends Domain { creationDate: this.creationDate.toISOString(), }; } + + /** + * Maps an SDK Fido2Credential object to a Fido2Credential + * @param obj - The SDK Fido2Credential object + */ + static fromSdkFido2Credential(obj: SdkFido2Credential): Fido2Credential | undefined { + if (!obj) { + return undefined; + } + + const credential = new Fido2Credential(); + + credential.credentialId = EncString.fromJSON(obj.credentialId); + credential.keyType = EncString.fromJSON(obj.keyType); + credential.keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); + credential.keyCurve = EncString.fromJSON(obj.keyCurve); + credential.keyValue = EncString.fromJSON(obj.keyValue); + credential.rpId = EncString.fromJSON(obj.rpId); + credential.userHandle = EncString.fromJSON(obj.userHandle); + credential.userName = EncString.fromJSON(obj.userName); + credential.counter = EncString.fromJSON(obj.counter); + credential.rpName = EncString.fromJSON(obj.rpName); + credential.userDisplayName = EncString.fromJSON(obj.userDisplayName); + credential.discoverable = EncString.fromJSON(obj.discoverable); + credential.creationDate = new Date(obj.creationDate); + + return credential; + } } diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index 08bc0da84fe..b5e26199e7d 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -1,6 +1,14 @@ +import { + Field as SdkField, + FieldType, + LoginLinkedIdType, + CardLinkedIdType, + IdentityLinkedIdType, +} from "@bitwarden/sdk-internal"; + import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; -import { CardLinkedId, FieldType, IdentityLinkedId, LoginLinkedId } from "../../enums"; +import { CardLinkedId, IdentityLinkedId, LoginLinkedId } from "../../enums"; import { FieldData } from "../../models/data/field.data"; import { Field } from "../../models/domain/field"; @@ -103,5 +111,34 @@ describe("Field", () => { identityField.linkedId = IdentityLinkedId.LicenseNumber; expect(identityField.toSdkField().linkedId).toBe(415); }); + + it("should map from SDK Field", () => { + // Test Login LinkedId + const loginField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: LoginLinkedIdType.Username, + }; + expect(Field.fromSdkField(loginField)!.linkedId).toBe(100); + + // Test Card LinkedId + const cardField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: CardLinkedIdType.Number, + }; + expect(Field.fromSdkField(cardField)!.linkedId).toBe(305); + + // Test Identity LinkedId + const identityFieldSdkField: SdkField = { + name: undefined, + value: undefined, + type: FieldType.Linked, + linkedId: IdentityLinkedIdType.LicenseNumber, + }; + expect(Field.fromSdkField(identityFieldSdkField)!.linkedId).toBe(415); + }); }); }); diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index d6453932cc7..53756e21046 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -90,4 +90,22 @@ export class Field extends Domain { linkedId: this.linkedId as unknown as SdkLinkedIdType, }; } + + /** + * Maps SDK Field to Field + * @param obj The SDK Field object to map + */ + static fromSdkField(obj: SdkField): Field | undefined { + if (!obj) { + return undefined; + } + + const field = new Field(); + field.name = EncString.fromJSON(obj.name); + field.value = EncString.fromJSON(obj.value); + field.type = obj.type; + field.linkedId = obj.linkedId; + + return field; + } } diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 5dc752531be..16e68c72551 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -195,4 +195,36 @@ export class Identity extends Domain { licenseNumber: this.licenseNumber?.toJSON(), }; } + + /** + * Maps an SDK Identity object to an Identity + * @param obj - The SDK Identity object + */ + static fromSdkIdentity(obj: SdkIdentity): Identity | undefined { + if (obj == null) { + return undefined; + } + + const identity = new Identity(); + identity.title = EncString.fromJSON(obj.title); + identity.firstName = EncString.fromJSON(obj.firstName); + identity.middleName = EncString.fromJSON(obj.middleName); + identity.lastName = EncString.fromJSON(obj.lastName); + identity.address1 = EncString.fromJSON(obj.address1); + identity.address2 = EncString.fromJSON(obj.address2); + identity.address3 = EncString.fromJSON(obj.address3); + identity.city = EncString.fromJSON(obj.city); + identity.state = EncString.fromJSON(obj.state); + identity.postalCode = EncString.fromJSON(obj.postalCode); + identity.country = EncString.fromJSON(obj.country); + identity.company = EncString.fromJSON(obj.company); + identity.email = EncString.fromJSON(obj.email); + identity.phone = EncString.fromJSON(obj.phone); + identity.ssn = EncString.fromJSON(obj.ssn); + identity.username = EncString.fromJSON(obj.username); + identity.passportNumber = EncString.fromJSON(obj.passportNumber); + identity.licenseNumber = EncString.fromJSON(obj.licenseNumber); + + return identity; + } } diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index e7bc2e8892e..9cfa4951dd8 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -102,4 +102,17 @@ export class LoginUri extends Domain { match: this.match, }; } + + static fromSdkLoginUri(obj: SdkLoginUri): LoginUri | undefined { + if (obj == null) { + return undefined; + } + + const view = new LoginUri(); + view.uri = EncString.fromJSON(obj.uri); + view.uriChecksum = obj.uriChecksum ? EncString.fromJSON(obj.uriChecksum) : undefined; + view.match = obj.match; + + return view; + } } diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index 4d77983d4af..93af2269185 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -163,4 +163,31 @@ export class Login extends Domain { fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()), }; } + + /** + * Maps an SDK Login object to a Login + * @param obj - The SDK Login object + */ + static fromSdkLogin(obj: SdkLogin): Login | undefined { + if (!obj) { + return undefined; + } + + const login = new Login(); + + login.uris = + obj.uris?.filter((u) => u.uri != null).map((uri) => LoginUri.fromSdkLoginUri(uri)) ?? []; + login.username = EncString.fromJSON(obj.username); + login.password = EncString.fromJSON(obj.password); + login.passwordRevisionDate = obj.passwordRevisionDate + ? new Date(obj.passwordRevisionDate) + : undefined; + login.totp = EncString.fromJSON(obj.totp); + login.autofillOnPageLoad = obj.autofillOnPageLoad ?? false; + login.fido2Credentials = obj.fido2Credentials?.map((f) => + Fido2Credential.fromSdkFido2Credential(f), + ); + + return login; + } } diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index f8aacf765bf..b8a30099454 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -71,4 +71,20 @@ export class Password extends Domain { lastUsedDate: this.lastUsedDate.toISOString(), }; } + + /** + * Maps an SDK PasswordHistory object to a Password + * @param obj - The SDK PasswordHistory object + */ + static fromSdkPasswordHistory(obj: PasswordHistory): Password | undefined { + if (!obj) { + return undefined; + } + + const passwordHistory = new Password(); + passwordHistory.password = EncString.fromJSON(obj.password); + passwordHistory.lastUsedDate = new Date(obj.lastUsedDate); + + return passwordHistory; + } } diff --git a/libs/common/src/vault/models/domain/secure-note.ts b/libs/common/src/vault/models/domain/secure-note.ts index ac7977b0e46..1426ff85eab 100644 --- a/libs/common/src/vault/models/domain/secure-note.ts +++ b/libs/common/src/vault/models/domain/secure-note.ts @@ -54,4 +54,19 @@ export class SecureNote extends Domain { type: this.type, }; } + + /** + * Maps an SDK SecureNote object to a SecureNote + * @param obj - The SDK SecureNote object + */ + static fromSdkSecureNote(obj: SdkSecureNote): SecureNote | undefined { + if (obj == null) { + return undefined; + } + + const secureNote = new SecureNote(); + secureNote.type = obj.type; + + return secureNote; + } } diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index c0afcd83fc2..0c8abf76e44 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -85,4 +85,21 @@ export class SshKey extends Domain { fingerprint: this.keyFingerprint.toJSON(), }; } + + /** + * Maps an SDK SshKey object to a SshKey + * @param obj - The SDK SshKey object + */ + static fromSdkSshKey(obj: SdkSshKey): SshKey | undefined { + if (obj == null) { + return undefined; + } + + const sshKey = new SshKey(); + sshKey.privateKey = EncString.fromJSON(obj.privateKey); + sshKey.publicKey = EncString.fromJSON(obj.publicKey); + sshKey.keyFingerprint = EncString.fromJSON(obj.fingerprint); + + return sshKey; + } } diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index dd7f5d6be57..ed02fa68365 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator"; import { ItemView } from "./item.view"; -export class CardView extends ItemView { +export class CardView extends ItemView implements SdkCardView { @linkedFieldOption(LinkedId.CardholderName, { sortPosition: 0 }) cardholderName: string = null; @linkedFieldOption(LinkedId.ExpMonth, { sortPosition: 3, i18nKey: "expirationMonth" }) @@ -168,4 +168,12 @@ export class CardView extends ItemView { return cardView; } + + /** + * Converts the CardView to an SDK CardView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkCardView(): SdkCardView { + return this; + } } diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index b9d3e42aa62..46cea06979f 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -1,3 +1,7 @@ +import { Jsonify } from "type-fest"; + +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { CipherPermissionsApi } from "@bitwarden/common/vault/models/api/cipher-permissions.api"; import { CipherView as SdkCipherView, CipherType as SdkCipherType, @@ -85,6 +89,25 @@ describe("CipherView", () => { expect(actual).toMatchObject(expected); }); + + it("handle both string and object inputs for the cipher key", () => { + const cipherKeyString = "cipherKeyString"; + const cipherKeyObject = new EncString("cipherKeyObject"); + + // Test with string input + let actual = CipherView.fromJSON({ + key: cipherKeyString, + }); + expect(actual.key).toBeInstanceOf(EncString); + expect(actual.key?.toJSON()).toBe(cipherKeyString); + + // Test with object input (which can happen when cipher view is stored in an InMemory state provider) + actual = CipherView.fromJSON({ + key: cipherKeyObject, + } as Jsonify<CipherView>); + expect(actual.key).toBeInstanceOf(EncString); + expect(actual.key?.toJSON()).toBe(cipherKeyObject.toJSON()); + }); }); describe("fromSdkCipherView", () => { @@ -196,11 +219,80 @@ describe("CipherView", () => { __fromSdk: true, }, ], - passwordHistory: null, + passwordHistory: [], creationDate: new Date("2022-01-01T12:00:00.000Z"), revisionDate: new Date("2022-01-02T12:00:00.000Z"), deletedDate: null, }); }); }); + + describe("toSdkCipherView", () => { + it("maps properties correctly", () => { + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.key = new EncString("some-key"); + cipherView.name = "name"; + cipherView.notes = "notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.edit = true; + cipherView.viewPassword = false; + cipherView.reprompt = CipherRepromptType.None; + cipherView.organizationUseTotp = false; + cipherView.localData = { + lastLaunched: new Date("2022-01-01T12:00:00.000Z").getTime(), + lastUsedDate: new Date("2022-01-02T12:00:00.000Z").getTime(), + }; + cipherView.permissions = new CipherPermissionsApi(); + cipherView.permissions.restore = true; + cipherView.permissions.delete = true; + cipherView.attachments = []; + cipherView.fields = []; + cipherView.passwordHistory = []; + cipherView.login = new LoginView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.creationDate = new Date("2022-01-02T12:00:00.000Z"); + + const sdkCipherView = cipherView.toSdkCipherView(); + + expect(sdkCipherView).toMatchObject({ + id: "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602", + organizationId: "000f2a6e-da5e-4726-87ed-1c5c77322c3c", + folderId: "41b22db4-8e2a-4ed2-b568-f1186c72922f", + collectionIds: ["b0473506-3c3c-4260-a734-dfaaf833ab6f"], + key: "some-key", + name: "name", + notes: "notes", + type: SdkCipherType.Login, + favorite: true, + edit: true, + viewPassword: false, + reprompt: SdkCipherRepromptType.None, + organizationUseTotp: false, + localData: { + lastLaunched: "2022-01-01T12:00:00.000Z", + lastUsedDate: "2022-01-02T12:00:00.000Z", + }, + permissions: { + restore: true, + delete: true, + }, + deletedDate: undefined, + creationDate: "2022-01-02T12:00:00.000Z", + revisionDate: "2022-01-02T12:00:00.000Z", + attachments: [], + passwordHistory: [], + login: undefined, + identity: undefined, + card: undefined, + secureNote: undefined, + sshKey: undefined, + fields: [], + } as SdkCipherView); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 353fffa8eef..0c41e49c3ab 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { uuidToString, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; @@ -9,7 +11,7 @@ import { DeepJsonify } from "../../../types/deep-jsonify"; import { CipherType, LinkedIdType } from "../../enums"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; -import { LocalData } from "../data/local.data"; +import { LocalData, toSdkLocalData, fromSdkLocalData } from "../data/local.data"; import { Cipher } from "../domain/cipher"; import { AttachmentView } from "./attachment.view"; @@ -41,14 +43,17 @@ export class CipherView implements View, InitializerMetadata { card = new CardView(); secureNote = new SecureNoteView(); sshKey = new SshKeyView(); - attachments: AttachmentView[] = null; - fields: FieldView[] = null; - passwordHistory: PasswordHistoryView[] = null; + attachments: AttachmentView[] = []; + fields: FieldView[] = []; + passwordHistory: PasswordHistoryView[] = []; collectionIds: string[] = null; revisionDate: Date = null; creationDate: Date = null; deletedDate: Date = null; reprompt: CipherRepromptType = CipherRepromptType.None; + // We need a copy of the encrypted key so we can pass it to + // the SdkCipherView during encryption + key?: EncString; /** * Flag to indicate if the cipher decryption failed. @@ -76,6 +81,7 @@ export class CipherView implements View, InitializerMetadata { this.deletedDate = c.deletedDate; // Old locally stored ciphers might have reprompt == null. If so set it to None. this.reprompt = c.reprompt ?? CipherRepromptType.None; + this.key = c.key; } private get item() { @@ -194,6 +200,18 @@ export class CipherView implements View, InitializerMetadata { const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a)); const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f)); const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph)); + const permissions = CipherPermissionsApi.fromJSON(obj.permissions); + let key: EncString | undefined; + + if (obj.key != null) { + if (typeof obj.key === "string") { + // If the key is a string, we need to parse it as EncString + key = EncString.fromJSON(obj.key); + } else if ((obj.key as any) instanceof EncString) { + // If the key is already an EncString instance, we can use it directly + key = obj.key; + } + } Object.assign(view, obj, { creationDate: creationDate, @@ -202,6 +220,8 @@ export class CipherView implements View, InitializerMetadata { attachments: attachments, fields: fields, passwordHistory: passwordHistory, + permissions: permissions, + key: key, }); switch (obj.type) { @@ -236,9 +256,9 @@ export class CipherView implements View, InitializerMetadata { } const cipherView = new CipherView(); - cipherView.id = obj.id ?? null; - cipherView.organizationId = obj.organizationId ?? null; - cipherView.folderId = obj.folderId ?? null; + cipherView.id = uuidToString(obj.id) ?? null; + cipherView.organizationId = uuidToString(obj.organizationId) ?? null; + cipherView.folderId = uuidToString(obj.folderId) ?? null; cipherView.name = obj.name; cipherView.notes = obj.notes ?? null; cipherView.type = obj.type; @@ -247,26 +267,18 @@ export class CipherView implements View, InitializerMetadata { cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions); cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; - cipherView.localData = obj.localData - ? { - lastUsedDate: obj.localData.lastUsedDate - ? new Date(obj.localData.lastUsedDate).getTime() - : undefined, - lastLaunched: obj.localData.lastLaunched - ? new Date(obj.localData.lastLaunched).getTime() - : undefined, - } - : undefined; + cipherView.localData = fromSdkLocalData(obj.localData); cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? null; - cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? null; + obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? []; + cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? []; cipherView.passwordHistory = - obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? null; - cipherView.collectionIds = obj.collectionIds ?? null; + obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? []; + cipherView.collectionIds = obj.collectionIds?.map((i) => uuidToString(i)) ?? []; cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate); cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None; + cipherView.key = EncString.fromJSON(obj.key); switch (obj.type) { case CipherType.Card: @@ -290,4 +302,66 @@ export class CipherView implements View, InitializerMetadata { return cipherView; } + + /** + * Maps CipherView to SdkCipherView + * + * @returns {SdkCipherView} The SDK cipher view object + */ + toSdkCipherView(): SdkCipherView { + const sdkCipherView: SdkCipherView = { + id: this.id ? asUuid(this.id) : undefined, + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + type: this.type ?? CipherType.Login, + favorite: this.favorite, + organizationUseTotp: this.organizationUseTotp, + permissions: this.permissions?.toSdkCipherPermissions(), + edit: this.edit, + viewPassword: this.viewPassword, + localData: toSdkLocalData(this.localData), + attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), + fields: this.fields?.map((f) => f.toSdkFieldView()), + passwordHistory: this.passwordHistory?.map((ph) => ph.toSdkPasswordHistoryView()), + collectionIds: this.collectionIds?.map((i) => i) ?? [], + // Revision and creation dates are non-nullable in SDKCipherView + revisionDate: (this.revisionDate ?? new Date()).toISOString(), + creationDate: (this.creationDate ?? new Date()).toISOString(), + deletedDate: this.deletedDate?.toISOString(), + reprompt: this.reprompt ?? CipherRepromptType.None, + key: this.key?.toJSON(), + // Cipher type specific properties are set in the switch statement below + // CipherView initializes each with default constructors (undefined values) + // The SDK does not expect those undefined values and will throw exceptions + login: undefined, + card: undefined, + identity: undefined, + secureNote: undefined, + sshKey: undefined, + }; + + switch (this.type) { + case CipherType.Card: + sdkCipherView.card = this.card.toSdkCardView(); + break; + case CipherType.Identity: + sdkCipherView.identity = this.identity.toSdkIdentityView(); + break; + case CipherType.Login: + sdkCipherView.login = this.login.toSdkLoginView(); + break; + case CipherType.SecureNote: + sdkCipherView.secureNote = this.secureNote.toSdkSecureNoteView(); + break; + case CipherType.SshKey: + sdkCipherView.sshKey = this.sshKey.toSdkSshKeyView(); + break; + default: + break; + } + + return sdkCipherView; + } } diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts index bf1d324d22d..410757ebe30 100644 --- a/libs/common/src/vault/models/view/fido2-credential.view.ts +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -2,7 +2,10 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; -import { Fido2CredentialView as SdkFido2CredentialView } from "@bitwarden/sdk-internal"; +import { + Fido2CredentialView as SdkFido2CredentialView, + Fido2CredentialFullView, +} from "@bitwarden/sdk-internal"; import { ItemView } from "./item.view"; @@ -56,4 +59,22 @@ export class Fido2CredentialView extends ItemView { return view; } + + toSdkFido2CredentialFullView(): Fido2CredentialFullView { + return { + credentialId: this.credentialId, + keyType: this.keyType, + keyAlgorithm: this.keyAlgorithm, + keyCurve: this.keyCurve, + keyValue: this.keyValue, + rpId: this.rpId, + userHandle: this.userHandle, + userName: this.userName, + counter: this.counter.toString(), + rpName: this.rpName, + userDisplayName: this.userDisplayName, + discoverable: this.discoverable ? "true" : "false", + creationDate: this.creationDate?.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/view/field.view.ts b/libs/common/src/vault/models/view/field.view.ts index 770150f8a63..8c9a923aed2 100644 --- a/libs/common/src/vault/models/view/field.view.ts +++ b/libs/common/src/vault/models/view/field.view.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; -import { FieldView as SdkFieldView } from "@bitwarden/sdk-internal"; +import { FieldView as SdkFieldView, FieldType as SdkFieldType } from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; import { FieldType, LinkedIdType } from "../../enums"; @@ -50,4 +50,16 @@ export class FieldView implements View { return view; } + + /** + * Converts the FieldView to an SDK FieldView. + */ + toSdkFieldView(): SdkFieldView { + return { + name: this.name ?? undefined, + value: this.value ?? undefined, + type: this.type ?? SdkFieldType.Text, + linkedId: this.linkedId ?? undefined, + }; + } } diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 877940e4aea..2b863dc5e5f 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -10,7 +10,7 @@ import { linkedFieldOption } from "../../linked-field-option.decorator"; import { ItemView } from "./item.view"; -export class IdentityView extends ItemView { +export class IdentityView extends ItemView implements SdkIdentityView { @linkedFieldOption(LinkedId.Title, { sortPosition: 0 }) title: string = null; @linkedFieldOption(LinkedId.MiddleName, { sortPosition: 2 }) @@ -192,4 +192,12 @@ export class IdentityView extends ItemView { return identityView; } + + /** + * Converts the IdentityView to an SDK IdentityView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkIdentityView(): SdkIdentityView { + return this; + } } diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 43d47aa4a3c..38cd517e542 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -129,6 +129,15 @@ export class LoginUriView implements View { return view; } + /** Converts a LoginUriView object to an SDK LoginUriView object. */ + toSdkLoginUriView(): SdkLoginUriView { + return { + uri: this.uri ?? undefined, + match: this.match ?? undefined, + uriChecksum: undefined, // SDK handles uri checksum generation internally + }; + } + matchesUri( targetUri: string, equivalentDomains: Set<string>, diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index c6e6ca001e4..d268cf4afaa 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -124,10 +124,30 @@ export class LoginView extends ItemView { obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); loginView.totp = obj.totp ?? null; loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null; - loginView.uris = obj.uris?.map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; + loginView.uris = + obj.uris + ?.filter((uri) => uri.uri != null && uri.uri !== "") + .map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; // FIDO2 credentials are not decrypted here, they remain encrypted loginView.fido2Credentials = null; return loginView; } + + /** + * Converts the LoginView to an SDK LoginView. + * + * Note: FIDO2 credentials remain encrypted in the SDK view so they are not included here. + */ + toSdkLoginView(): SdkLoginView { + return { + username: this.username, + password: this.password, + passwordRevisionDate: this.passwordRevisionDate?.toISOString(), + totp: this.totp, + autofillOnPageLoad: this.autofillOnPageLoad ?? undefined, + uris: this.uris?.map((uri) => uri.toSdkLoginUriView()), + fido2Credentials: undefined, // FIDO2 credentials are handled separately and remain encrypted + }; + } } diff --git a/libs/common/src/vault/models/view/password-history.view.spec.ts b/libs/common/src/vault/models/view/password-history.view.spec.ts index 81894ec7493..512ec8d86d8 100644 --- a/libs/common/src/vault/models/view/password-history.view.spec.ts +++ b/libs/common/src/vault/models/view/password-history.view.spec.ts @@ -33,4 +33,17 @@ describe("PasswordHistoryView", () => { }); }); }); + + describe("toSdkPasswordHistoryView", () => { + it("should return a SdkPasswordHistoryView", () => { + const passwordHistoryView = new PasswordHistoryView(); + passwordHistoryView.password = "password"; + passwordHistoryView.lastUsedDate = new Date("2023-10-01T00:00:00.000Z"); + + expect(passwordHistoryView.toSdkPasswordHistoryView()).toMatchObject({ + password: "password", + lastUsedDate: "2023-10-01T00:00:00.000Z", + }); + }); + }); }); diff --git a/libs/common/src/vault/models/view/password-history.view.ts b/libs/common/src/vault/models/view/password-history.view.ts index 31f05f4cc71..9bd708b19fd 100644 --- a/libs/common/src/vault/models/view/password-history.view.ts +++ b/libs/common/src/vault/models/view/password-history.view.ts @@ -41,4 +41,14 @@ export class PasswordHistoryView implements View { return view; } + + /** + * Converts the PasswordHistoryView to an SDK PasswordHistoryView. + */ + toSdkPasswordHistoryView(): SdkPasswordHistoryView { + return { + password: this.password ?? "", + lastUsedDate: this.lastUsedDate.toISOString(), + }; + } } diff --git a/libs/common/src/vault/models/view/secure-note.view.ts b/libs/common/src/vault/models/view/secure-note.view.ts index 8e7a6b4652d..5e401961869 100644 --- a/libs/common/src/vault/models/view/secure-note.view.ts +++ b/libs/common/src/vault/models/view/secure-note.view.ts @@ -9,7 +9,7 @@ import { SecureNote } from "../domain/secure-note"; import { ItemView } from "./item.view"; -export class SecureNoteView extends ItemView { +export class SecureNoteView extends ItemView implements SdkSecureNoteView { type: SecureNoteType = null; constructor(n?: SecureNote) { @@ -42,4 +42,12 @@ export class SecureNoteView extends ItemView { return secureNoteView; } + + /** + * Converts the SecureNoteView to an SDK SecureNoteView. + * The view implements the SdkView so we can safely return `this` + */ + toSdkSecureNoteView(): SdkSecureNoteView { + return this; + } } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts index a83793678dc..0547eeb7f8e 100644 --- a/libs/common/src/vault/models/view/ssh-key.view.ts +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -63,4 +63,15 @@ export class SshKeyView extends ItemView { return sshKeyView; } + + /** + * Converts the SshKeyView to an SDK SshKeyView. + */ + toSdkSshKeyView(): SdkSshKeyView { + return { + privateKey: this.privateKey, + publicKey: this.publicKey, + fingerprint: this.keyFingerprint, + }; + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index bf30b78ca63..f027122993d 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,7 +1,9 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, map, of } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CipherDecryptionKeys, KeyService } from "@bitwarden/key-management"; @@ -23,7 +25,7 @@ import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../platform/services/container.service"; -import { CipherId, UserId } from "../../types/guid"; +import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { EncryptionContext } from "../abstractions/cipher.service"; @@ -108,6 +110,7 @@ describe("Cipher Service", () => { const cipherEncryptionService = mock<CipherEncryptionService>(); const userId = "TestUserId" as UserId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId; let cipherService: CipherService; let encryptionContext: EncryptionContext; @@ -155,7 +158,9 @@ describe("Cipher Service", () => { ); configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false)); - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); const spy = jest.spyOn(cipherFileUploadService, "upload"); @@ -270,6 +275,55 @@ describe("Cipher Service", () => { jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true); }); + it("should call encrypt method of CipherEncryptionService when feature flag is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + cipherEncryptionService.encrypt.mockResolvedValue(encryptionContext); + + const result = await cipherService.encrypt(cipherView, userId); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).toHaveBeenCalledWith(cipherView, userId); + }); + + it("should call legacy encrypt when feature flag is false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(false); + + jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher); + + const result = await cipherService.encrypt(cipherView, userId); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + }); + + it("should call legacy encrypt when keys are provided", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + + jest.spyOn(cipherService as any, "encryptCipher").mockResolvedValue(encryptionContext.cipher); + + const encryptKey = new SymmetricCryptoKey(new Uint8Array(32)); + const decryptKey = new SymmetricCryptoKey(new Uint8Array(32)); + + let result = await cipherService.encrypt(cipherView, userId, encryptKey); + + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + + result = await cipherService.encrypt(cipherView, userId, undefined, decryptKey); + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + + result = await cipherService.encrypt(cipherView, userId, encryptKey, decryptKey); + expect(result).toEqual(encryptionContext); + expect(cipherEncryptionService.encrypt).not.toHaveBeenCalled(); + }); + it("should return the encrypting user id", async () => { keyService.getOrgKey.mockReturnValue( Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey), @@ -310,7 +364,9 @@ describe("Cipher Service", () => { }); it("is null when feature flag is false", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); const { cipher } = await cipherService.encrypt(cipherView, userId); expect(cipher.key).toBeNull(); @@ -318,7 +374,9 @@ describe("Cipher Service", () => { describe("when feature flag is true", () => { beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); }); it("is null when the cipher is not viewPassword", async () => { @@ -348,7 +406,9 @@ describe("Cipher Service", () => { }); it("is not called when feature flag is false", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); await cipherService.encrypt(cipherView, userId); @@ -357,7 +417,9 @@ describe("Cipher Service", () => { describe("when feature flag is true", () => { beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); }); it("is called when cipher viewPassword is true", async () => { @@ -401,7 +463,9 @@ describe("Cipher Service", () => { let encryptedKey: EncString; beforeEach(() => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(true); configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true)); searchService.indexedEntityId$.mockReturnValue(of(null)); @@ -474,7 +538,9 @@ describe("Cipher Service", () => { describe("decrypt", () => { it("should call decrypt method of CipherEncryptionService when feature flag is true", async () => { - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(true); cipherEncryptionService.decrypt.mockResolvedValue(new CipherView(encryptionContext.cipher)); const result = await cipherService.decrypt(encryptionContext.cipher, userId); @@ -488,7 +554,9 @@ describe("Cipher Service", () => { it("should call legacy decrypt when feature flag is false", async () => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(false); cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey); encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); jest @@ -509,7 +577,9 @@ describe("Cipher Service", () => { it("should use SDK when feature flag is enabled", async () => { const cipher = new Cipher(cipherData); const attachment = new AttachmentView(cipher.attachments![0]); - configService.getFeatureFlag.mockResolvedValue(true); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(true); jest.spyOn(cipherService, "ciphers$").mockReturnValue(of({ [cipher.id]: cipherData })); cipherEncryptionService.decryptAttachmentContent.mockResolvedValue(mockDecryptedContent); @@ -534,7 +604,9 @@ describe("Cipher Service", () => { }); it("should use legacy decryption when feature flag is enabled", async () => { - configService.getFeatureFlag.mockResolvedValue(false); + configService.getFeatureFlag + .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) + .mockResolvedValue(false); const cipher = new Cipher(cipherData); const attachment = new AttachmentView(cipher.attachments![0]); attachment.key = makeSymmetricCryptoKey(64); @@ -557,4 +629,77 @@ describe("Cipher Service", () => { expect(encryptService.decryptFileData).toHaveBeenCalledWith(mockEncBuf, attachment.key); }); }); + + describe("shareWithServer()", () => { + it("should use cipherEncryptionService to move the cipher when feature flag enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + + apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData)); + + const expectedCipher = new Cipher(cipherData); + expectedCipher.organizationId = orgId; + const cipherView = new CipherView(expectedCipher); + const collectionIds = ["collection1", "collection2"] as CollectionId[]; + + cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test + + cipherEncryptionService.moveToOrganization.mockResolvedValue({ + cipher: expectedCipher, + encryptedFor: userId, + }); + + await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId); + + // Expect SDK usage + expect(cipherEncryptionService.moveToOrganization).toHaveBeenCalledWith( + cipherView, + orgId, + userId, + ); + // Expect collectionIds to be assigned + expect(apiService.putShareCipher).toHaveBeenCalledWith( + cipherView.id, + expect.objectContaining({ + cipher: expect.objectContaining({ organizationId: orgId }), + collectionIds: collectionIds, + }), + ); + }); + + it("should use legacy encryption when feature flag disabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(false); + + apiService.putShareCipher.mockResolvedValue(new CipherResponse(cipherData)); + + const expectedCipher = new Cipher(cipherData); + expectedCipher.organizationId = orgId; + const cipherView = new CipherView(expectedCipher); + const collectionIds = ["collection1", "collection2"] as CollectionId[]; + + cipherView.organizationId = undefined; // Ensure organizationId is undefined for this test + + const oldEncryptSharedSpy = jest + .spyOn(cipherService as any, "encryptSharedCipher") + .mockResolvedValue({ + cipher: expectedCipher, + encryptedFor: userId, + }); + + await cipherService.shareWithServer(cipherView, orgId, collectionIds, userId); + + // Expect no SDK usage + expect(cipherEncryptionService.moveToOrganization).not.toHaveBeenCalled(); + expect(oldEncryptSharedSpy).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: orgId, + collectionIds: collectionIds, + } as unknown as CipherView), + userId, + ); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 8bef5289a95..1524e4e1b29 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -231,13 +231,14 @@ export class CipherService implements CipherServiceAbstraction { this.clearCipherViewsForUser$.next(userId); } - async encrypt( - model: CipherView, - userId: UserId, - keyForCipherEncryption?: SymmetricCryptoKey, - keyForCipherKeyDecryption?: SymmetricCryptoKey, - originalCipher: Cipher = null, - ): Promise<EncryptionContext> { + /** + * Adjusts the cipher history for the given model by updating its history properties based on the original cipher. + * @param model The cipher model to adjust. + * @param userId The acting userId + * @param originalCipher The original cipher to compare against. If not provided, it will be fetched from the store. + * @private + */ + private async adjustCipherHistory(model: CipherView, userId: UserId, originalCipher?: Cipher) { if (model.id != null) { if (originalCipher == null) { originalCipher = await this.get(model.id, userId); @@ -247,6 +248,25 @@ export class CipherService implements CipherServiceAbstraction { } this.adjustPasswordHistoryLength(model); } + } + + async encrypt( + model: CipherView, + userId: UserId, + keyForCipherEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, + originalCipher: Cipher = null, + ): Promise<EncryptionContext> { + await this.adjustCipherHistory(model, userId, originalCipher); + + const sdkEncryptionEnabled = + (await this.configService.getFeatureFlag(FeatureFlag.PM22136_SdkCipherEncryption)) && + keyForCipherEncryption == null && // PM-23085 - SDK encryption does not currently support custom keys (e.g. key rotation) + keyForCipherKeyDecryption == null; // PM-23348 - Or has explicit methods for re-encrypting ciphers with different keys (e.g. move to org) + + if (sdkEncryptionEnabled) { + return await this.cipherEncryptionService.encrypt(model, userId); + } const cipher = new Cipher(); cipher.id = model.id; @@ -854,22 +874,48 @@ export class CipherService implements CipherServiceAbstraction { organizationId: string, collectionIds: string[], userId: UserId, + originalCipher?: Cipher, ): Promise<Cipher> { - const attachmentPromises: Promise<any>[] = []; - if (cipher.attachments != null) { - cipher.attachments.forEach((attachment) => { - if (attachment.key == null) { - attachmentPromises.push( - this.shareAttachmentWithServer(attachment, cipher.id, organizationId), - ); - } - }); - } - await Promise.all(attachmentPromises); + const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); + + await this.adjustCipherHistory(cipher, userId, originalCipher); + + let encCipher: EncryptionContext; + if (sdkCipherEncryptionEnabled) { + // The SDK does not expect the cipher to already have an organizationId. It will result in the wrong + // cipher encryption key being used during the move to organization operation. + if (cipher.organizationId != null) { + throw new Error("Cipher is already associated with an organization."); + } + + encCipher = await this.cipherEncryptionService.moveToOrganization( + cipher, + organizationId as OrganizationId, + userId, + ); + encCipher.cipher.collectionIds = collectionIds; + } else { + // This old attachment logic is safe to remove after it is replaced in PM-22750; which will require fixing + // the attachment before sharing. + const attachmentPromises: Promise<any>[] = []; + if (cipher.attachments != null) { + cipher.attachments.forEach((attachment) => { + if (attachment.key == null) { + attachmentPromises.push( + this.shareAttachmentWithServer(attachment, cipher.id, organizationId), + ); + } + }); + } + await Promise.all(attachmentPromises); + + cipher.organizationId = organizationId; + cipher.collectionIds = collectionIds; + encCipher = await this.encryptSharedCipher(cipher, userId); + } - cipher.organizationId = organizationId; - cipher.collectionIds = collectionIds; - const encCipher = await this.encryptSharedCipher(cipher, userId); const request = new CipherShareRequest(encCipher); const response = await this.apiService.putShareCipher(cipher.id, request); const data = new CipherData(response, collectionIds); @@ -883,16 +929,36 @@ export class CipherService implements CipherServiceAbstraction { collectionIds: string[], userId: UserId, ) { + const sdkCipherEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); const promises: Promise<any>[] = []; const encCiphers: Cipher[] = []; for (const cipher of ciphers) { - cipher.organizationId = organizationId; - cipher.collectionIds = collectionIds; - promises.push( - this.encryptSharedCipher(cipher, userId).then((c) => { - encCiphers.push(c.cipher); - }), - ); + if (sdkCipherEncryptionEnabled) { + // The SDK does not expect the cipher to already have an organizationId. It will result in the wrong + // cipher encryption key being used during the move to organization operation. + if (cipher.organizationId != null) { + throw new Error("Cipher is already associated with an organization."); + } + + promises.push( + this.cipherEncryptionService + .moveToOrganization(cipher, organizationId as OrganizationId, userId) + .then((encCipher) => { + encCipher.cipher.collectionIds = collectionIds; + encCiphers.push(encCipher.cipher); + }), + ); + } else { + cipher.organizationId = organizationId; + cipher.collectionIds = collectionIds; + promises.push( + this.encryptSharedCipher(cipher, userId).then((c) => { + encCiphers.push(c.cipher); + }), + ); + } } await Promise.all(promises); const request = new CipherBulkShareRequest(encCiphers, collectionIds, userId); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 4d05a5197fb..9e0cf62ed08 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -1,20 +1,22 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential"; import { - Fido2Credential, + Fido2Credential as SdkFido2Credential, Cipher as SdkCipher, CipherType as SdkCipherType, CipherView as SdkCipherView, CipherListView, AttachmentView as SdkAttachmentView, + Fido2CredentialFullView, } from "@bitwarden/sdk-internal"; import { mockEnc } from "../../../spec"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { LogService } from "../../platform/abstractions/log.service"; import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; -import { UserId } from "../../types/guid"; +import { UserId, CipherId, OrganizationId } from "../../types/guid"; import { CipherRepromptType, CipherType } from "../enums"; import { CipherPermissionsApi } from "../models/api/cipher-permissions.api"; import { CipherData } from "../models/data/cipher.data"; @@ -25,10 +27,15 @@ import { Fido2CredentialView } from "../models/view/fido2-credential.view"; import { DefaultCipherEncryptionService } from "./default-cipher-encryption.service"; +const cipherId = "bdc4ef23-1116-477e-ae73-247854af58cb" as CipherId; +const orgId = "c5e9654f-6cc5-44c4-8e09-3d323522668c" as OrganizationId; +const folderId = "a3e9654f-6cc5-44c4-8e09-3d323522668c"; +const userId = "59fbbb44-8cc8-4279-ab40-afc5f68704f4" as UserId; + const cipherData: CipherData = { - id: "id", - organizationId: "orgId", - folderId: "folderId", + id: cipherId, + organizationId: orgId, + folderId: folderId, edit: true, viewPassword: true, organizationUseTotp: true, @@ -78,13 +85,17 @@ describe("DefaultCipherEncryptionService", () => { const sdkService = mock<SdkService>(); const logService = mock<LogService>(); let sdkCipherView: SdkCipherView; + let sdkCipher: SdkCipher; const mockSdkClient = { vault: jest.fn().mockReturnValue({ ciphers: jest.fn().mockReturnValue({ + encrypt: jest.fn(), + set_fido2_credentials: jest.fn(), decrypt: jest.fn(), decrypt_list: jest.fn(), decrypt_fido2_credentials: jest.fn(), + move_to_organization: jest.fn(), }), attachments: jest.fn().mockReturnValue({ decrypt_buffer: jest.fn(), @@ -99,21 +110,25 @@ describe("DefaultCipherEncryptionService", () => { take: jest.fn().mockReturnValue(mockRef), }; - const userId = "user-id" as UserId; - let cipherObj: Cipher; + let cipherViewObj: CipherView; beforeEach(() => { sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any; cipherEncryptionService = new DefaultCipherEncryptionService(sdkService, logService); cipherObj = new Cipher(cipherData); + cipherViewObj = new CipherView(cipherObj); jest.spyOn(cipherObj, "toSdkCipher").mockImplementation(() => { return { id: cipherData.id } as SdkCipher; }); + jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation(() => { + return { id: cipherData.id } as SdkCipherView; + }); + sdkCipherView = { - id: "test-id", + id: cipherId as string, type: SdkCipherType.Login, name: "test-name", login: { @@ -121,16 +136,211 @@ describe("DefaultCipherEncryptionService", () => { password: "test-password", }, } as SdkCipherView; + + sdkCipher = { + id: cipherId, + type: SdkCipherType.Login, + name: "encrypted-name", + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as SdkCipher; }); afterEach(() => { jest.clearAllMocks(); }); + describe("encrypt", () => { + it("should encrypt a cipher successfully", async () => { + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.encrypt(cipherViewObj, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith({ id: cipherData.id }); + }); + + it("should encrypt FIDO2 credentials if present", async () => { + const fidoCredentialView = new Fido2CredentialView(); + fidoCredentialView.credentialId = "credentialId"; + + cipherViewObj.login.fido2Credentials = [fidoCredentialView]; + + jest.spyOn(fidoCredentialView, "toSdkFido2CredentialFullView").mockImplementation( + () => + ({ + credentialId: "credentialId", + }) as Fido2CredentialFullView, + ); + jest.spyOn(cipherViewObj, "toSdkCipherView").mockImplementation( + () => + ({ + id: cipherId as string, + login: { + fido2Credentials: undefined, + }, + }) as unknown as SdkCipherView, + ); + + mockSdkClient + .vault() + .ciphers() + .set_fido2_credentials.mockReturnValue({ + id: cipherId as string, + login: { + fido2Credentials: [ + { + credentialId: "encrypted-credentialId", + }, + ], + }, + }); + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + + cipherObj.login!.fido2Credentials = [ + { credentialId: "encrypted-credentialId" } as unknown as Fido2Credential, + ]; + + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(cipherObj); + + const result = await cipherEncryptionService.encrypt(cipherViewObj, userId); + + expect(result).toBeDefined(); + expect(result!.cipher.login!.fido2Credentials).toHaveLength(1); + + // Ensure set_fido2_credentials was called with correct parameters + expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith( + expect.objectContaining({ id: cipherId }), + [{ credentialId: "credentialId" }], + ); + + // Encrypted fido2 credential should be in the cipher passed to encrypt + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + id: cipherId, + login: { fido2Credentials: [{ credentialId: "encrypted-credentialId" }] }, + }), + ); + }); + }); + + describe("moveToOrganization", () => { + it("should call the sdk method to move a cipher to an organization", async () => { + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + organizationId: orgId, + login: { + username: "encrypted-username", + password: "encrypted-password", + }, + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({ + id: cipherId, + organizationId: orgId, + }); + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith( + { id: cipherData.id }, + orgId, + ); + }); + + it("should re-encrypt any fido2 credentials when moving to an organization", async () => { + const mockSdkCredentialView = { + username: "username", + } as unknown as Fido2CredentialFullView; + const mockCredentialView = mock<Fido2CredentialView>(); + mockCredentialView.toSdkFido2CredentialFullView.mockReturnValue(mockSdkCredentialView); + cipherViewObj.login.fido2Credentials = [mockCredentialView]; + const expectedCipher: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name", + organizationId: orgId, + login: { + username: "encrypted-username", + password: "encrypted-password", + fido2Credentials: [{ username: "encrypted-username" }], + }, + } as unknown as Cipher; + + mockSdkClient + .vault() + .ciphers() + .set_fido2_credentials.mockReturnValue({ + id: cipherId as string, + login: { + fido2Credentials: [mockSdkCredentialView], + }, + } as SdkCipherView); + mockSdkClient.vault().ciphers().move_to_organization.mockReturnValue({ + id: cipherId, + organizationId: orgId, + }); + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + jest.spyOn(Cipher, "fromSdkCipher").mockReturnValue(expectedCipher); + + const result = await cipherEncryptionService.moveToOrganization(cipherViewObj, orgId, userId); + + expect(result).toBeDefined(); + expect(result!.cipher).toEqual(expectedCipher); + expect(result!.encryptedFor).toBe(userId); + expect(cipherViewObj.toSdkCipherView).toHaveBeenCalled(); + expect(mockSdkClient.vault().ciphers().set_fido2_credentials).toHaveBeenCalledWith( + expect.objectContaining({ id: cipherId }), + expect.arrayContaining([mockSdkCredentialView]), + ); + expect(mockSdkClient.vault().ciphers().move_to_organization).toHaveBeenCalledWith( + { id: cipherData.id, login: { fido2Credentials: [mockSdkCredentialView] } }, + orgId, + ); + }); + }); + describe("decrypt", () => { it("should decrypt a cipher successfully", async () => { const expectedCipherView: CipherView = { - id: "test-id", + id: cipherId as string, type: CipherType.Login, name: "test-name", login: { @@ -168,12 +378,12 @@ describe("DefaultCipherEncryptionService", () => { discoverable: mockEnc("true"), creationDate: new Date("2023-01-01T12:00:00.000Z"), }, - ] as unknown as Fido2Credential[]; + ] as unknown as SdkFido2Credential[]; sdkCipherView.login!.fido2Credentials = fido2Credentials; const expectedCipherView: CipherView = { - id: "test-id", + id: cipherId, type: CipherType.Login, name: "test-name", login: { @@ -228,13 +438,15 @@ describe("DefaultCipherEncryptionService", () => { it("should decrypt multiple ciphers successfully", async () => { const ciphers = [new Cipher(cipherData), new Cipher(cipherData)]; + const cipherId2 = "bdc4ef23-2222-477e-ae73-247854af58cb" as CipherId; + const expectedViews = [ { - id: "test-id-1", + id: cipherId as string, name: "test-name-1", } as CipherView, { - id: "test-id-2", + id: cipherId2 as string, name: "test-name-2", } as CipherView, ]; @@ -242,8 +454,11 @@ describe("DefaultCipherEncryptionService", () => { mockSdkClient .vault() .ciphers() - .decrypt.mockReturnValueOnce({ id: "test-id-1", name: "test-name-1" } as SdkCipherView) - .mockReturnValueOnce({ id: "test-id-2", name: "test-name-2" } as SdkCipherView); + .decrypt.mockReturnValueOnce({ + id: cipherId, + name: "test-name-1", + } as unknown as SdkCipherView) + .mockReturnValueOnce({ id: cipherId2, name: "test-name-2" } as unknown as SdkCipherView); jest .spyOn(CipherView, "fromSdkCipherView") diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 2c57df6f5bb..3547bafb4c9 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -1,10 +1,15 @@ import { EMPTY, catchError, firstValueFrom, map } from "rxjs"; -import { CipherListView } from "@bitwarden/sdk-internal"; +import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + CipherListView, + BitwardenClient, + CipherView as SdkCipherView, +} from "@bitwarden/sdk-internal"; import { LogService } from "../../platform/abstractions/log.service"; -import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; -import { UserId } from "../../types/guid"; +import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service"; +import { UserId, OrganizationId } from "../../types/guid"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; import { CipherType } from "../enums"; import { Cipher } from "../models/domain/cipher"; @@ -18,6 +23,67 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { private logService: LogService, ) {} + async encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined> { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = this.toSdkCipherView(model, ref.value); + + const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView); + + return { + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: asUuid<UserId>(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to encrypt cipher: ${error}`); + return EMPTY; + }), + ), + ); + } + + async moveToOrganization( + model: CipherView, + organizationId: OrganizationId, + userId: UserId, + ): Promise<EncryptionContext | undefined> { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = this.toSdkCipherView(model, ref.value); + + const movedCipherView = ref.value + .vault() + .ciphers() + .move_to_organization(sdkCipherView, asUuid(organizationId)); + + const encryptionContext = ref.value.vault().ciphers().encrypt(movedCipherView); + + return { + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: asUuid<UserId>(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to move cipher to organization: ${error}`); + return EMPTY; + }), + ), + ); + } + async decrypt(cipher: Cipher, userId: UserId): Promise<CipherView> { return firstValueFrom( this.sdkService.userClient$(userId).pipe( @@ -51,11 +117,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { clientCipherView.login.fido2Credentials = fido2CredentialViews .map((f) => { const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - - return { - ...view, - keyValue: decryptedKeyValue, - }; + view.keyValue = decryptedKeyValue; + return view; }) .filter((view): view is Fido2CredentialView => view !== undefined); } @@ -104,10 +167,8 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { clientCipherView.login.fido2Credentials = fido2CredentialViews .map((f) => { const view = Fido2CredentialView.fromSdkFido2CredentialView(f)!; - return { - ...view, - keyValue: decryptedKeyValue, - }; + view.keyValue = decryptedKeyValue; + return view; }) .filter((view): view is Fido2CredentialView => view !== undefined); } @@ -187,4 +248,25 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ), ); } + + /** + * Helper method to convert a CipherView model to an SDK CipherView. Has special handling for Fido2 credentials + * that need to be encrypted before being sent to the SDK. + * @param model The CipherView model to convert + * @param sdk An instance of SDK client + * @private + */ + private toSdkCipherView(model: CipherView, sdk: BitwardenClient): SdkCipherView { + let sdkCipherView = model.toSdkCipherView(); + + if (model.type === CipherType.Login && model.login?.hasFido2Credentials) { + // Encrypt Fido2 credentials separately + const fido2Credentials = model.login.fido2Credentials?.map((f) => + f.toSdkFido2CredentialFullView(), + ); + sdkCipherView = sdk.vault().ciphers().set_fido2_credentials(sdkCipherView, fido2Credentials); + } + + return sdkCipherView; + } } diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 463d61dbbdf..1a97bc5a325 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -320,12 +320,6 @@ export abstract class BaseImporter { } else { cipher.notes = cipher.notes.trim(); } - if (cipher.fields != null && cipher.fields.length === 0) { - cipher.fields = null; - } - if (cipher.passwordHistory != null && cipher.passwordHistory.length === 0) { - cipher.passwordHistory = null; - } } protected processKvp( diff --git a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts index d7a4d487bcb..026c501cf5a 100644 --- a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts @@ -66,7 +66,7 @@ describe("Keeper CSV Importer", () => { expect(result != null).toBe(true); const cipher = result.ciphers.shift(); - expect(cipher.fields).toBeNull(); + expect(cipher.fields.length).toBe(0); const cipher2 = result.ciphers.shift(); expect(cipher2.fields.length).toBe(2); diff --git a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts index 31169021e0c..22008f3b4c1 100644 --- a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts @@ -39,7 +39,7 @@ describe("Keeper Json Importer", () => { expect(cipher3.login.username).toEqual("someUserName"); expect(cipher3.login.password).toEqual("w4k4k1wergf$^&@#*%2"); expect(cipher3.notes).toBeNull(); - expect(cipher3.fields).toBeNull(); + expect(cipher3.fields.length).toBe(0); expect(cipher3.login.uris.length).toEqual(1); const uriView3 = cipher3.login.uris.shift(); expect(uriView3.uri).toEqual("https://example.com"); diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 5228c85c3f7..d195ff8b00b 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -5,7 +5,7 @@ import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; +import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -31,21 +31,13 @@ export class DefaultCipherFormService implements CipherFormService { } async saveCipher(cipher: CipherView, config: CipherFormConfig): Promise<CipherView> { - // Passing the original cipher is important here as it is responsible for appending to password history const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const encrypted = await this.cipherService.encrypt( - cipher, - activeUserId, - null, - null, - config.originalCipher ?? null, - ); - const encryptedCipher = encrypted.cipher; let savedCipher: Cipher; // Creating a new cipher if (cipher.id == null) { + const encrypted = await this.cipherService.encrypt(cipher, activeUserId); savedCipher = await this.cipherService.createWithServer(encrypted, config.admin); return await this.cipherService.decrypt(savedCipher, activeUserId); } @@ -61,16 +53,37 @@ export class DefaultCipherFormService implements CipherFormService { // Call shareWithServer if the owner is changing from a user to an organization if (config.originalCipher.organizationId === null && cipher.organizationId != null) { + // shareWithServer expects the cipher to have no organizationId set + const organizationId = cipher.organizationId as OrganizationId; + cipher.organizationId = null; + savedCipher = await this.cipherService.shareWithServer( cipher, - cipher.organizationId, + organizationId, cipher.collectionIds, activeUserId, + config.originalCipher, ); // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { + const encrypted = await this.cipherService.encrypt( + cipher, + activeUserId, + null, + null, + config.originalCipher, + ); savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin); } else { + const encrypted = await this.cipherService.encrypt( + cipher, + activeUserId, + null, + null, + config.originalCipher, + ); + const encryptedCipher = encrypted.cipher; + // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index a2ade0e885c..e65dd62500e 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -67,12 +67,12 @@ </ng-container> <!-- CUSTOM FIELDS --> - <ng-container *ngIf="cipher.fields"> + <ng-container *ngIf="cipher.hasFields"> <app-custom-fields-v2 [cipher]="cipher"> </app-custom-fields-v2> </ng-container> <!-- ATTACHMENTS SECTION --> - <ng-container *ngIf="cipher.attachments"> + <ng-container *ngIf="cipher.hasAttachments"> <app-attachments-v2-view [emergencyAccessId]="emergencyAccessId" [cipher]="cipher" diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts index c535847f7ba..2a9ca3b8433 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -9,6 +9,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; import { ColorPasswordComponent, ColorPasswordModule, ItemModule } from "@bitwarden/components"; import { PasswordHistoryViewComponent } from "./password-history-view.component"; @@ -54,8 +55,14 @@ describe("PasswordHistoryViewComponent", () => { }); describe("history", () => { - const password1 = { password: "bad-password-1", lastUsedDate: new Date("09/13/2004") }; - const password2 = { password: "bad-password-2", lastUsedDate: new Date("02/01/2004") }; + const password1 = { + password: "bad-password-1", + lastUsedDate: new Date("09/13/2004"), + } as PasswordHistoryView; + const password2 = { + password: "bad-password-2", + lastUsedDate: new Date("02/01/2004"), + } as PasswordHistoryView; beforeEach(async () => { mockCipher.passwordHistory = [password1, password2]; diff --git a/package-lock.json b/package-lock.json index e6d4a0b9b89..b9f661f6915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.225", + "@bitwarden/sdk-internal": "0.2.0-main.227", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4604,9 +4604,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.225", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.225.tgz", - "integrity": "sha512-bhSFNX584GPJ9wMBYff1d18/Hfj+o+D4E1l3uDLZNXRI9s7w919AQWqJ0xUy1vh8gpkLJovkf64HQGqs0OiQQA==", + "version": "0.2.0-main.227", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.227.tgz", + "integrity": "sha512-afOsl9jwi1qyX/tF4bYP3EWXgc8oMgnCA0hPPh+AJpn7GgoAPCi+WXaJkbBPwRpxZFKEpwt3oLRNTvtkECvFJw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index b7923a92f6f..ac00e674310 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.225", + "@bitwarden/sdk-internal": "0.2.0-main.227", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From b54944da416925c90fabc0479d2cc20b4c3f32da Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Tue, 22 Jul 2025 12:35:55 +0200 Subject: [PATCH 33/54] Deprecate encstring's decrypt function (#15703) --- libs/common/src/key-management/crypto/models/enc-string.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/common/src/key-management/crypto/models/enc-string.ts b/libs/common/src/key-management/crypto/models/enc-string.ts index 1ff98d1b6b6..3478ced0cf3 100644 --- a/libs/common/src/key-management/crypto/models/enc-string.ts +++ b/libs/common/src/key-management/crypto/models/enc-string.ts @@ -153,6 +153,10 @@ export class EncString implements Encrypted { return EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE[encType] === encPieces.length; } + /** + * @deprecated - This function is deprecated. Use EncryptService.decryptString instead. + * @returns - The decrypted string, or `[error: cannot decrypt]` if decryption fails. + */ async decrypt( orgId: string | null, key: SymmetricCryptoKey | null = null, From 481910b82374631454a173816f4b24ba2336437d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Tue, 22 Jul 2025 13:03:04 +0200 Subject: [PATCH 34/54] Fix breaking sdk change and update to 231 (#15617) --- .../src/platform/services/sdk/default-sdk.service.ts | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 1dfdfd207c0..c12fbe2dbb2 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -228,6 +228,7 @@ export class DefaultSdkService implements SdkService { }, privateKey, signingKey: undefined, + securityState: undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/package-lock.json b/package-lock.json index b9f661f6915..e44797997f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.227", + "@bitwarden/sdk-internal": "0.2.0-main.231", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4604,9 +4604,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.227", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.227.tgz", - "integrity": "sha512-afOsl9jwi1qyX/tF4bYP3EWXgc8oMgnCA0hPPh+AJpn7GgoAPCi+WXaJkbBPwRpxZFKEpwt3oLRNTvtkECvFJw==", + "version": "0.2.0-main.231", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.231.tgz", + "integrity": "sha512-fDKB/RFVvkRPWlhL/qhPAdJDjD1EpFjpEjjpY0v5QNGalh6NCztOr1OcMc4kvipPp4g+epZjs3SPN38K6R+7zw==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index ac00e674310..089ef3342e9 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.227", + "@bitwarden/sdk-internal": "0.2.0-main.231", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From 2a07b952ef5c633ea4b470f64039cb13c02c0aa4 Mon Sep 17 00:00:00 2001 From: Shane Melton <smelton@bitwarden.com> Date: Tue, 22 Jul 2025 06:32:00 -0700 Subject: [PATCH 35/54] [PM-24000] Convert string date values to Date objects for CipherExport types (#15715) --- libs/common/src/models/export/cipher.export.ts | 7 ++++--- libs/common/src/models/export/password-history.export.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index ad6d8b7a609..d343621328c 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -53,6 +53,7 @@ export class CipherExport { view.notes = req.notes; view.favorite = req.favorite; view.reprompt = req.reprompt ?? CipherRepromptType.None; + view.key = req.key != null ? new EncString(req.key) : null; if (req.fields != null) { view.fields = req.fields.map((f) => FieldExport.toView(f)); @@ -80,9 +81,9 @@ export class CipherExport { view.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toView(ph)); } - view.creationDate = req.creationDate; - view.revisionDate = req.revisionDate; - view.deletedDate = req.deletedDate; + view.creationDate = req.creationDate ? new Date(req.creationDate) : null; + view.revisionDate = req.revisionDate ? new Date(req.revisionDate) : null; + view.deletedDate = req.deletedDate ? new Date(req.deletedDate) : null; return view; } diff --git a/libs/common/src/models/export/password-history.export.ts b/libs/common/src/models/export/password-history.export.ts index e5a44e4e330..f443a2f4ace 100644 --- a/libs/common/src/models/export/password-history.export.ts +++ b/libs/common/src/models/export/password-history.export.ts @@ -16,7 +16,7 @@ export class PasswordHistoryExport { static toView(req: PasswordHistoryExport, view = new PasswordHistoryView()) { view.password = req.password; - view.lastUsedDate = req.lastUsedDate; + view.lastUsedDate = req.lastUsedDate ? new Date(req.lastUsedDate) : null; return view; } From 5290e0a63beb5ec5f134c996c7e25e3814cbce4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= <ajensen@bitwarden.com> Date: Tue, 22 Jul 2025 09:33:34 -0400 Subject: [PATCH 36/54] [PM-19054] configure send with email otp authentication via cli (#15360) --- apps/cli/src/commands/get.command.ts | 10 +- .../src/tools/send/commands/create.command.ts | 10 +- .../src/tools/send/commands/edit.command.ts | 24 ++- apps/cli/src/tools/send/commands/index.ts | 1 + .../tools/send/commands/template.command.ts | 35 ++++ .../src/tools/send/models/send.response.ts | 3 + apps/cli/src/tools/send/send.program.ts | 64 +++--- apps/cli/src/tools/send/util.spec.ts | 194 ++++++++++++++++++ apps/cli/src/tools/send/util.ts | 55 +++++ .../src/tools/send/models/data/send.data.ts | 2 + .../src/tools/send/models/domain/send.spec.ts | 6 +- .../src/tools/send/models/domain/send.ts | 2 + .../tools/send/models/request/send.request.ts | 2 + .../send/models/response/send.response.ts | 2 + .../src/tools/send/models/view/send.view.ts | 1 + .../src/tools/send/services/send.service.ts | 7 +- 16 files changed, 369 insertions(+), 49 deletions(-) create mode 100644 apps/cli/src/tools/send/commands/template.command.ts create mode 100644 apps/cli/src/tools/send/util.spec.ts create mode 100644 apps/cli/src/tools/send/util.ts diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index aa2db7c81ab..b20052fbb53 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -25,7 +25,6 @@ import { LoginExport } from "@bitwarden/common/models/export/login.export"; import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -44,7 +43,6 @@ import { SelectionReadOnly } from "../admin-console/models/selection-read-only"; import { Response } from "../models/response"; import { StringResponse } from "../models/response/string.response"; import { TemplateResponse } from "../models/response/template.response"; -import { SendResponse } from "../tools/send/models/send.response"; import { CliUtils } from "../utils"; import { CipherResponse } from "../vault/models/cipher.response"; import { FolderResponse } from "../vault/models/folder.response"; @@ -577,11 +575,11 @@ export class GetCommand extends DownloadCommand { case "org-collection": template = OrganizationCollectionRequest.template(); break; - case "send.text": - template = SendResponse.template(SendType.Text); - break; case "send.file": - template = SendResponse.template(SendType.File); + case "send.text": + template = Response.badRequest( + `Invalid template object. Use \`bw send template ${id}\` instead.`, + ); break; default: return Response.badRequest("Unknown template object."); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index a9264c50126..d4f544d39b7 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -76,9 +76,14 @@ export class SendCreateCommand { const filePath = req.file?.fileName ?? options.file; const text = req.text?.text ?? options.text; const hidden = req.text?.hidden ?? options.hidden; - const password = req.password ?? options.password; + const password = req.password ?? options.password ?? undefined; + const emails = req.emails ?? options.emails ?? undefined; const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount; + if (emails !== undefined && password !== undefined) { + return Response.badRequest("--password and --emails are mutually exclusive."); + } + req.key = null; req.maxAccessCount = maxAccessCount; @@ -133,6 +138,7 @@ export class SendCreateCommand { // Add dates from template encSend.deletionDate = sendView.deletionDate; encSend.expirationDate = sendView.expirationDate; + encSend.emails = emails && emails.join(","); await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); @@ -151,12 +157,14 @@ class Options { text: string; maxAccessCount: number; password: string; + emails: Array<string>; hidden: boolean; constructor(passedOptions: Record<string, any>) { this.file = passedOptions?.file; this.text = passedOptions?.text; this.password = passedOptions?.password; + this.emails = passedOptions?.email; this.hidden = CliUtils.convertBooleanOption(passedOptions?.hidden); this.maxAccessCount = passedOptions?.maxAccessCount != null ? parseInt(passedOptions.maxAccessCount, null) : null; diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index ed719b58311..09f89041cc5 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -50,11 +50,21 @@ export class SendEditCommand { const normalizedOptions = new Options(cmdOptions); req.id = normalizedOptions.itemId || req.id; - - if (req.id != null) { - req.id = req.id.toLowerCase(); + if (normalizedOptions.emails) { + req.emails = normalizedOptions.emails; + req.password = undefined; + } else if (normalizedOptions.password) { + req.emails = undefined; + req.password = normalizedOptions.password; + } else if (req.password && (typeof req.password !== "string" || req.password === "")) { + req.password = undefined; } + if (!req.id) { + return Response.error("`itemid` was not provided."); + } + + req.id = req.id.toLowerCase(); const send = await this.sendService.getFromState(req.id); if (send == null) { @@ -76,10 +86,6 @@ export class SendEditCommand { let sendView = await send.decrypt(); sendView = SendResponse.toView(req, sendView); - if (typeof req.password !== "string" || req.password === "") { - req.password = null; - } - try { const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password); // Add dates from template @@ -97,8 +103,12 @@ export class SendEditCommand { class Options { itemId: string; + password: string; + emails: string[]; constructor(passedOptions: Record<string, any>) { this.itemId = passedOptions?.itemId || passedOptions?.itemid; + this.password = passedOptions.password; + this.emails = passedOptions.email; } } diff --git a/apps/cli/src/tools/send/commands/index.ts b/apps/cli/src/tools/send/commands/index.ts index 645f5c0d1db..452c228dd9b 100644 --- a/apps/cli/src/tools/send/commands/index.ts +++ b/apps/cli/src/tools/send/commands/index.ts @@ -5,3 +5,4 @@ export * from "./get.command"; export * from "./list.command"; export * from "./receive.command"; export * from "./remove-password.command"; +export * from "./template.command"; diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts new file mode 100644 index 00000000000..c1c2c97b03d --- /dev/null +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -0,0 +1,35 @@ +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; + +import { Response } from "../../../models/response"; +import { TemplateResponse } from "../../../models/response/template.response"; +import { SendResponse } from "../models/send.response"; + +export class SendTemplateCommand { + constructor() {} + + run(type: string): Response { + let template: SendResponse | undefined; + let response: Response; + + switch (type) { + case "send.text": + case "text": + template = SendResponse.template(SendType.Text); + break; + case "send.file": + case "file": + template = SendResponse.template(SendType.File); + break; + default: + response = Response.badRequest("Unknown template object."); + } + + if (template) { + response = Response.success(new TemplateResponse(template)); + } + + response ??= Response.badRequest("An error occurred while retrieving the template."); + + return response; + } +} diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index 4d680b5c0a1..a0c1d3f83c6 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -26,6 +26,7 @@ export class SendResponse implements BaseResponse { req.deletionDate = this.getStandardDeletionDate(deleteInDays); req.expirationDate = null; req.password = null; + req.emails = null; req.disabled = false; req.hideEmail = false; return req; @@ -50,6 +51,7 @@ export class SendResponse implements BaseResponse { view.deletionDate = send.deletionDate; view.expirationDate = send.expirationDate; view.password = send.password; + view.emails = send.emails ?? []; view.disabled = send.disabled; view.hideEmail = send.hideEmail; return view; @@ -87,6 +89,7 @@ export class SendResponse implements BaseResponse { expirationDate: Date; password: string; passwordSet: boolean; + emails?: Array<string>; disabled: boolean; hideEmail: boolean; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index cbeda188a99..650f448e558 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -4,13 +4,12 @@ import * as fs from "fs"; import * as path from "path"; import * as chalk from "chalk"; -import { program, Command, OptionValues } from "commander"; +import { program, Command, Option, OptionValues } from "commander"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { BaseProgram } from "../../base-program"; -import { GetCommand } from "../../commands/get.command"; import { Response } from "../../models/response"; import { CliUtils } from "../../utils"; @@ -22,10 +21,12 @@ import { SendListCommand, SendReceiveCommand, SendRemovePasswordCommand, + SendTemplateCommand, } from "./commands"; import { SendFileResponse } from "./models/send-file.response"; import { SendTextResponse } from "./models/send-text.response"; import { SendResponse } from "./models/send.response"; +import { parseEmail } from "./util"; const writeLn = CliUtils.writeLn; @@ -48,6 +49,17 @@ export class SendProgram extends BaseProgram { "The number of days in the future to set deletion date, defaults to 7", "7", ) + .addOption( + new Option( + "--password <password>", + "optional password to access this Send. Can also be specified in JSON.", + ).conflicts("email"), + ) + .option( + "--email <email>", + "optional emails to access this Send. Can also be specified in JSON.", + parseEmail, + ) .option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.") .option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.") .option( @@ -139,26 +151,9 @@ export class SendProgram extends BaseProgram { return new Command("template") .argument("<object>", "Valid objects are: send.text, send.file") .description("Get json templates for send objects") - .action(async (object) => { - const cmd = new GetCommand( - this.serviceContainer.cipherService, - this.serviceContainer.folderService, - this.serviceContainer.collectionService, - this.serviceContainer.totpService, - this.serviceContainer.auditService, - this.serviceContainer.keyService, - this.serviceContainer.encryptService, - this.serviceContainer.searchService, - this.serviceContainer.apiService, - this.serviceContainer.organizationService, - this.serviceContainer.eventCollectionService, - this.serviceContainer.billingAccountProfileStateService, - this.serviceContainer.accountService, - this.serviceContainer.cliRestrictedItemTypesService, - ); - const response = await cmd.run("template", object, null); - this.processResponse(response); - }); + .action((options: OptionValues) => + this.processResponse(new SendTemplateCommand().run(options.object)), + ); } private getCommand(): Command { @@ -208,10 +203,6 @@ export class SendProgram extends BaseProgram { .option("--file <path>", "file to Send. Can also be specified in parent's JSON.") .option("--text <text>", "text to Send. Can also be specified in parent's JSON.") .option("--hidden", "text hidden flag. Valid only with the --text option.") - .option( - "--password <password>", - "optional password to access this Send. Can also be specified in JSON", - ) .on("--help", () => { writeLn(""); writeLn("Note:"); @@ -219,13 +210,13 @@ export class SendProgram extends BaseProgram { writeLn("", true); }) .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { - // Work-around to support `--fullObject` option for `send create --fullObject` - // Calling `option('--fullObject', ...)` above won't work due to Commander doesn't like same option - // to be defind on both parent-command and sub-command - const { fullObject = false } = args.parent.opts(); + // subcommands inherit flags from their parent; they cannot override them + const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); const mergedOptions = { ...options, fullObject: fullObject, + email, + password, }; const response = await this.runCreate(encodedJson, mergedOptions); @@ -247,7 +238,7 @@ export class SendProgram extends BaseProgram { writeLn(" You cannot update a File-type Send's file. Just delete and remake it"); writeLn("", true); }) - .action(async (encodedJson: string, options: OptionValues) => { + .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { await this.exitIfLocked(); const getCmd = new SendGetCommand( this.serviceContainer.sendService, @@ -264,7 +255,16 @@ export class SendProgram extends BaseProgram { this.serviceContainer.billingAccountProfileStateService, this.serviceContainer.accountService, ); - const response = await cmd.run(encodedJson, options); + + // subcommands inherit flags from their parent; they cannot override them + const { email = undefined, password = undefined } = args.parent.opts(); + const mergedOptions = { + ...options, + email, + password, + }; + + const response = await cmd.run(encodedJson, mergedOptions); this.processResponse(response); }); } diff --git a/apps/cli/src/tools/send/util.spec.ts b/apps/cli/src/tools/send/util.spec.ts new file mode 100644 index 00000000000..2cfc2a1b4c8 --- /dev/null +++ b/apps/cli/src/tools/send/util.spec.ts @@ -0,0 +1,194 @@ +import { parseEmail } from "./util"; + +describe("parseEmail", () => { + describe("single email address parsing", () => { + it("should parse a valid single email address", () => { + const result = parseEmail("test@example.com", []); + expect(result).toEqual(["test@example.com"]); + }); + + it("should parse email with dots in local part", () => { + const result = parseEmail("test.user@example.com", []); + expect(result).toEqual(["test.user@example.com"]); + }); + + it("should parse email with underscores and hyphens", () => { + const result = parseEmail("test_user-name@example.com", []); + expect(result).toEqual(["test_user-name@example.com"]); + }); + + it("should parse email with plus sign", () => { + const result = parseEmail("test+user@example.com", []); + expect(result).toEqual(["test+user@example.com"]); + }); + + it("should parse email with dots and hyphens in domain", () => { + const result = parseEmail("user@test-domain.co.uk", []); + expect(result).toEqual(["user@test-domain.co.uk"]); + }); + + it("should add single email to existing previousInput array", () => { + const result = parseEmail("new@example.com", ["existing@test.com"]); + expect(result).toEqual(["existing@test.com", "new@example.com"]); + }); + }); + + describe("comma-separated email lists", () => { + it("should parse comma-separated email list", () => { + const result = parseEmail("test@example.com,user@domain.com", []); + expect(result).toEqual(["test@example.com", "user@domain.com"]); + }); + + it("should parse comma-separated emails with spaces", () => { + const result = parseEmail("test@example.com, user@domain.com, admin@site.org", []); + expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]); + }); + + it("should combine comma-separated emails with previousInput", () => { + const result = parseEmail("new1@example.com,new2@domain.com", ["existing@test.com"]); + expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]); + }); + + it("should throw error for invalid email in comma-separated list", () => { + expect(() => { + parseEmail("valid@example.com,invalid-email,another@domain.com", []); + }).toThrow("Invalid email address: invalid-email"); + }); + }); + + describe("space-separated email lists", () => { + it("should parse space-separated email list", () => { + const result = parseEmail("test@example.com user@domain.com", []); + expect(result).toEqual(["test@example.com", "user@domain.com"]); + }); + + it("should parse space-separated emails with multiple spaces", () => { + const result = parseEmail("test@example.com user@domain.com admin@site.org", []); + expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]); + }); + + it("should combine space-separated emails with previousInput", () => { + const result = parseEmail("new1@example.com new2@domain.com", ["existing@test.com"]); + expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]); + }); + + it("should throw error for invalid email in space-separated list", () => { + expect(() => { + parseEmail("valid@example.com invalid-email another@domain.com", []); + }).toThrow("Invalid email address: invalid-email"); + }); + }); + + describe("JSON array input format", () => { + it("should parse valid JSON array of emails", () => { + const result = parseEmail('["test@example.com", "user@domain.com"]', []); + expect(result).toEqual(["test@example.com", "user@domain.com"]); + }); + + it("should parse single email in JSON array", () => { + const result = parseEmail('["test@example.com"]', []); + expect(result).toEqual(["test@example.com"]); + }); + + it("should parse empty JSON array", () => { + const result = parseEmail("[]", []); + expect(result).toEqual([]); + }); + + it("should combine JSON array with previousInput", () => { + const result = parseEmail('["new1@example.com", "new2@domain.com"]', ["existing@test.com"]); + expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]); + }); + + it("should throw error for malformed JSON", () => { + expect(() => { + parseEmail('["test@example.com", "user@domain.com"', []); + }).toThrow(); + }); + + it("should throw error for JSON that is not an array", () => { + expect(() => { + parseEmail('{"email": "test@example.com"}', []); + }).toThrow("Invalid email address:"); + }); + + it("should throw error for JSON string instead of array", () => { + expect(() => { + parseEmail('"test@example.com"', []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for JSON number instead of array", () => { + expect(() => { + parseEmail("123", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + }); + + describe("`previousInput` parameter handling", () => { + it("should handle undefined previousInput", () => { + const result = parseEmail("test@example.com", undefined as any); + expect(result).toEqual(["test@example.com"]); + }); + + it("should handle null previousInput", () => { + const result = parseEmail("test@example.com", null as any); + expect(result).toEqual(["test@example.com"]); + }); + + it("should preserve existing emails in previousInput", () => { + const existing = ["existing1@test.com", "existing2@test.com"]; + const result = parseEmail("new@example.com", existing); + expect(result).toEqual(["existing1@test.com", "existing2@test.com", "new@example.com"]); + }); + }); + + describe("error cases and edge conditions", () => { + it("should throw error for empty string input", () => { + expect(() => { + parseEmail("", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should return empty array for whitespace-only input", () => { + const result = parseEmail(" ", []); + expect(result).toEqual([]); + }); + + it("should throw error for invalid single email", () => { + expect(() => { + parseEmail("invalid-email", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for email without @ symbol", () => { + expect(() => { + parseEmail("testexample.com", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for email without domain", () => { + expect(() => { + parseEmail("test@", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for email without local part", () => { + expect(() => { + parseEmail("@example.com", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for input that looks like file path", () => { + expect(() => { + parseEmail("/path/to/file.txt", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + + it("should throw error for input that looks like URL", () => { + expect(() => { + parseEmail("https://example.com", []); + }).toThrow("`input` must be a single address, a comma-separated list, or a JSON array"); + }); + }); +}); diff --git a/apps/cli/src/tools/send/util.ts b/apps/cli/src/tools/send/util.ts new file mode 100644 index 00000000000..bf66f916bbb --- /dev/null +++ b/apps/cli/src/tools/send/util.ts @@ -0,0 +1,55 @@ +/** + * Parses email addresses from various input formats and combines them with previously parsed emails. + * + * Supports: single email, JSON array, comma-separated, or space-separated lists. + * Note: Function signature follows Commander.js option parsing pattern. + * + * @param input - Email input string in any supported format + * @param previousInput - Previously parsed email addresses to append to + * @returns Combined array of email addresses + * @throws {Error} For invalid JSON, non-array JSON, invalid email addresses, or unrecognized format + * + * @example + * parseEmail("user@example.com", []) // ["user@example.com"] + * parseEmail('["user1@example.com", "user2@example.com"]', []) // ["user1@example.com", "user2@example.com"] + * parseEmail("user1@example.com, user2@example.com", []) // ["user1@example.com", "user2@example.com"] + */ +export function parseEmail(input: string, previousInput: string[]) { + let result = previousInput ?? []; + + if (isEmail(input)) { + result.push(input); + } else if (input.startsWith("[")) { + const json = JSON.parse(input); + if (!Array.isArray(json)) { + throw new Error("invalid JSON"); + } + + result = result.concat(json); + } else if (input.includes(",")) { + result = result.concat(parseList(input, ",")); + } else if (input.includes(" ")) { + result = result.concat(parseList(input, " ")); + } else { + throw new Error("`input` must be a single address, a comma-separated list, or a JSON array"); + } + + return result; +} + +function isEmail(input: string) { + return !!input && !!input.match(/^([\w._+-]+?)@([\w._+-]+?)$/); +} + +function parseList(value: string, separator: string) { + const parts = value + .split(separator) + .map((v) => v.trim()) + .filter((v) => !!v.length); + const invalid = parts.find((v) => !isEmail(v)); + if (invalid) { + throw new Error(`Invalid email address: ${invalid}`); + } + + return parts; +} diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index e4df5e48dce..2c6377de0c9 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -21,6 +21,7 @@ export class SendData { expirationDate: string; deletionDate: string; password: string; + emails: string; disabled: boolean; hideEmail: boolean; @@ -41,6 +42,7 @@ export class SendData { this.expirationDate = response.expirationDate; this.deletionDate = response.deletionDate; this.password = response.password; + this.emails = response.emails; this.disabled = response.disable; this.hideEmail = response.hideEmail; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index 8df9a144108..e9b0ae7b3b8 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -29,14 +29,15 @@ describe("Send", () => { text: "encText", hidden: true, }, - file: null, + file: null!, key: "encKey", - maxAccessCount: null, + maxAccessCount: null!, accessCount: 10, revisionDate: "2022-01-31T12:00:00.000Z", expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", + emails: null!, disabled: false, hideEmail: true, }; @@ -86,6 +87,7 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", + emails: null!, disabled: false, hideEmail: true, }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 89fe92c2c7b..48057aedd2d 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -27,6 +27,7 @@ export class Send extends Domain { expirationDate: Date; deletionDate: Date; password: string; + emails: string; disabled: boolean; hideEmail: boolean; @@ -53,6 +54,7 @@ export class Send extends Domain { this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; + this.emails = obj.emails; this.disabled = obj.disabled; this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index 9e4f1e14837..f7e3ff26d7f 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -17,6 +17,7 @@ export class SendRequest { text: SendTextApi; file: SendFileApi; password: string; + emails: string; disabled: boolean; hideEmail: boolean; @@ -30,6 +31,7 @@ export class SendRequest { this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null; this.key = send.key != null ? send.key.encryptedString : null; this.password = send.password; + this.emails = send.emails; this.disabled = send.disabled; this.hideEmail = send.hideEmail; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 76550f5cdfd..5c6bd4dc1a6 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -20,6 +20,7 @@ export class SendResponse extends BaseResponse { expirationDate: string; deletionDate: string; password: string; + emails: string; disable: boolean; hideEmail: boolean; @@ -37,6 +38,7 @@ export class SendResponse extends BaseResponse { this.expirationDate = this.getResponseProperty("ExpirationDate"); this.deletionDate = this.getResponseProperty("DeletionDate"); this.password = this.getResponseProperty("Password"); + this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 2c269892a6f..54657b12438 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -26,6 +26,7 @@ export class SendView implements View { deletionDate: Date = null; expirationDate: Date = null; password: string = null; + emails: string[] = []; disabled = false; hideEmail = false; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 57463b3b42b..6e2b4391c96 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -74,7 +74,12 @@ export class SendService implements InternalSendServiceAbstraction { model.key = key.material; model.cryptoKey = key.derivedKey; } - if (password != null) { + + const hasEmails = (model.emails?.length ?? 0) > 0; + if (hasEmails) { + send.emails = model.emails.join(","); + send.password = null; + } else if (password != null) { // Note: Despite being called key, the passwordKey is not used for encryption. // It is used as a static proof that the client knows the password, and has the encryption key. const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( From 96f31aac3adcd3abcc917c523bcb7f671ab76ffa Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:58:17 +0100 Subject: [PATCH 37/54] [PM 18701]Optional payment modal after signup (#15384) * Implement the planservice * Add the pricing component and service * Add the change plan type service * resolve the unit test issues * Move the changeSubscriptionFrequency endpoint * Rename planservice to plancardservice * Remove unused and correct typos * Resolve the double asignment * resolve the unit test failing * Remove default payment setting to card * remove unnecessary check * Property initialPaymentMethod has no initializer * move the logic to service * Move estimate tax to pricing service * Refactor thr pricing summary component * Resolve the lint unit test error * Add changes for auto modal * Remove custom role for sm * Resolve the blank member page issue * Changes on the pricing display --- .../members/members.component.html | 5 + .../members/members.component.ts | 20 + .../organizations/members/members.module.ts | 2 + .../organization-payment-method.component.ts | 12 +- .../app/billing/services/plan-card.service.ts | 59 +++ .../services/pricing-summary.service.ts | 155 ++++++++ .../billing/shared/billing-shared.module.ts | 6 + .../shared/plan-card/plan-card.component.html | 45 +++ .../shared/plan-card/plan-card.component.ts | 68 ++++ .../pricing-summary.component.html | 259 +++++++++++++ .../pricing-summary.component.ts | 48 +++ .../trial-payment-dialog.component.html | 117 ++++++ .../trial-payment-dialog.component.ts | 365 ++++++++++++++++++ ...ganization-free-trial-warning.component.ts | 20 +- .../services/organization-warnings.service.ts | 34 +- apps/web/src/locales/en/messages.json | 29 +- ...ization-billing-api.service.abstraction.ts | 6 + .../request/change-plan-frequency.request.ts | 9 + .../organization-billing-api.service.ts | 14 + 19 files changed, 1264 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/billing/services/plan-card.service.ts create mode 100644 apps/web/src/app/billing/services/pricing-summary.service.ts create mode 100644 apps/web/src/app/billing/shared/plan-card/plan-card.component.html create mode 100644 apps/web/src/app/billing/shared/plan-card/plan-card.component.ts create mode 100644 apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html create mode 100644 apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts create mode 100644 apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html create mode 100644 apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts create mode 100644 libs/common/src/billing/models/request/change-plan-frequency.request.ts diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 962191021e8..49946806efc 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -1,3 +1,8 @@ +<app-organization-free-trial-warning + [organization]="organization" + (clicked)="navigateToPaymentMethod()" +> +</app-organization-free-trial-warning> <app-header> <bit-search class="tw-grow" diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 3f567c9673b..0eec835e15f 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -13,6 +13,7 @@ import { Observable, shareReplay, switchMap, + tap, } from "rxjs"; import { @@ -61,6 +62,7 @@ import { ChangePlanDialogResultType, openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; +import { OrganizationWarningsService } from "../../../billing/warnings/services"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupApiService } from "../core"; @@ -148,6 +150,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, private configService: ConfigService, private organizationUserService: OrganizationUserService, + private organizationWarningsService: OrganizationWarningsService, ) { super( apiService, @@ -247,6 +250,13 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ + .pipe( + takeUntilDestroyed(), + tap((org) => (this.organization = org)), + switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), + ) + .subscribe(); } async getUsers(): Promise<OrganizationUserView[]> { @@ -932,4 +942,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } + + async navigateToPaymentMethod() { + const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; + await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { + state: { launchPaymentModalAutomatically: true }, + }); + } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 98431758d2f..5f626d44161 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -5,6 +5,7 @@ import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-s import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; @@ -29,6 +30,7 @@ import { MembersComponent } from "./members.component"; ScrollingModule, PasswordStrengthV2Component, ScrollLayoutDirective, + OrganizationFreeTrialWarningComponent, ], declarations: [ BulkConfirmDialogComponent, diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts index 9b144fe59a7..aa7bf5e5d11 100644 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts @@ -36,6 +36,10 @@ import { AdjustPaymentDialogComponent, AdjustPaymentDialogResultType, } from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, +} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; import { FreeTrial } from "../../types/free-trial"; @Component({ @@ -212,15 +216,15 @@ export class OrganizationPaymentMethodComponent implements OnDestroy { }; changePayment = async () => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { data: { - initialPaymentMethod: this.paymentSource?.type, organizationId: this.organizationId, - productTier: this.organization?.productTierType, + subscription: this.organizationSubscriptionResponse, + productTierType: this.organization?.productTierType, }, }); const result = await lastValueFrom(dialogRef.closed); - if (result === AdjustPaymentDialogResultType.Submitted) { + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { this.location.replaceState(this.location.path(), "", {}); if (this.launchPaymentModalAutomatically && !this.organization.enabled) { await this.syncService.fullSync(true); diff --git a/apps/web/src/app/billing/services/plan-card.service.ts b/apps/web/src/app/billing/services/plan-card.service.ts new file mode 100644 index 00000000000..25974a428fd --- /dev/null +++ b/apps/web/src/app/billing/services/plan-card.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +@Injectable({ providedIn: "root" }) +export class PlanCardService { + constructor(private apiService: ApiService) {} + + async getCadenceCards( + currentPlan: PlanResponse, + subscription: OrganizationSubscriptionResponse, + isSecretsManagerTrial: boolean, + ) { + const plans = await this.apiService.getPlans(); + + const filteredPlans = plans.data.filter((plan) => !!plan.PasswordManager); + + const result = + filteredPlans?.filter( + (plan) => + plan.productTier === currentPlan.productTier && !plan.disabled && !plan.legacyYear, + ) || []; + + const planCards = result.map((plan) => { + let costPerMember = 0; + + if (plan.PasswordManager.basePrice) { + costPerMember = plan.isAnnual + ? plan.PasswordManager.basePrice / 12 + : plan.PasswordManager.basePrice; + } else if (!plan.PasswordManager.basePrice && plan.PasswordManager.hasAdditionalSeatsOption) { + const secretsManagerCost = subscription.useSecretsManager + ? plan.SecretsManager.seatPrice + : 0; + const passwordManagerCost = isSecretsManagerTrial ? 0 : plan.PasswordManager.seatPrice; + costPerMember = (secretsManagerCost + passwordManagerCost) / (plan.isAnnual ? 12 : 1); + } + + const percentOff = subscription.customerDiscount?.percentOff ?? 0; + + const discount = + (percentOff === 0 && plan.isAnnual) || isSecretsManagerTrial ? 20 : percentOff; + + return { + title: plan.isAnnual ? "Annually" : "Monthly", + costPerMember, + discount, + isDisabled: false, + isSelected: plan.isAnnual, + isAnnual: plan.isAnnual, + productTier: plan.productTier, + }; + }); + + return planCards.reverse(); + } +} diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts new file mode 100644 index 00000000000..0b048b379d8 --- /dev/null +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; +import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information"; +import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component"; + +@Injectable({ + providedIn: "root", +}) +export class PricingSummaryService { + private estimatedTax: number = 0; + + constructor(private taxService: TaxServiceAbstraction) {} + + async getPricingSummaryData( + plan: PlanResponse, + sub: OrganizationSubscriptionResponse, + organization: Organization, + selectedInterval: PlanInterval, + taxInformation: TaxInformation, + isSecretsManagerTrial: boolean, + ): Promise<PricingSummaryData> { + // Calculation helpers + const passwordManagerSeatTotal = + plan.PasswordManager?.hasAdditionalSeatsOption && !isSecretsManagerTrial + ? plan.PasswordManager.seatPrice * Math.abs(sub?.seats || 0) + : 0; + + const secretsManagerSeatTotal = plan.SecretsManager?.hasAdditionalSeatsOption + ? plan.SecretsManager.seatPrice * Math.abs(sub?.smSeats || 0) + : 0; + + const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub); + + const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption + ? plan.PasswordManager.additionalStoragePricePerGb * + (sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0) + : 0; + + const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0; + + const additionalServiceAccountTotal = + plan.SecretsManager?.hasAdditionalServiceAccountOption && additionalServiceAccount > 0 + ? plan.SecretsManager.additionalPricePerServiceAccount * additionalServiceAccount + : 0; + + let passwordManagerSubtotal = plan.PasswordManager?.basePrice || 0; + if (plan.PasswordManager?.hasAdditionalSeatsOption) { + passwordManagerSubtotal += passwordManagerSeatTotal; + } + if (plan.PasswordManager?.hasPremiumAccessOption) { + passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice; + } + + const secretsManagerSubtotal = plan.SecretsManager + ? (plan.SecretsManager.basePrice || 0) + + secretsManagerSeatTotal + + additionalServiceAccountTotal + : 0; + + const totalAppliedDiscount = 0; + const discountPercentageFromSub = isSecretsManagerTrial + ? 0 + : (sub?.customerDiscount?.percentOff ?? 0); + const discountPercentage = 20; + const acceptingSponsorship = false; + const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; + + this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation); + + const total = organization?.useSecretsManager + ? passwordManagerSubtotal + + additionalStorageTotal + + secretsManagerSubtotal + + this.estimatedTax + : passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax; + + return { + selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month", + passwordManagerSeats: + plan.productTier === ProductTierType.Families ? plan.PasswordManager.baseSeats : sub?.seats, + passwordManagerSeatTotal, + secretsManagerSeatTotal, + additionalStorageTotal, + additionalStoragePriceMonthly, + additionalServiceAccountTotal, + totalAppliedDiscount, + secretsManagerSubtotal, + passwordManagerSubtotal, + total, + organization, + sub, + selectedPlan: plan, + selectedInterval, + discountPercentageFromSub, + discountPercentage, + acceptingSponsorship, + additionalServiceAccount, + storageGb, + isSecretsManagerTrial, + estimatedTax: this.estimatedTax, + }; + } + + async getEstimatedTax( + organization: Organization, + currentPlan: PlanResponse, + sub: OrganizationSubscriptionResponse, + taxInformation: TaxInformation, + ) { + if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) { + return 0; + } + + const request: PreviewOrganizationInvoiceRequest = { + organizationId: organization.id, + passwordManager: { + additionalStorage: 0, + plan: currentPlan?.type, + seats: sub.seats, + }, + taxInformation: { + postalCode: taxInformation.postalCode, + country: taxInformation.country, + taxId: taxInformation.taxId, + }, + }; + + if (organization.useSecretsManager) { + request.secretsManager = { + seats: sub.smSeats ?? 0, + additionalMachineAccounts: + (sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0), + }; + } + const invoiceResponse = await this.taxService.previewOrganizationInvoice(request); + return invoiceResponse.taxAmount; + } + + getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number { + if (!plan || !plan.SecretsManager) { + return 0; + } + const baseServiceAccount = plan.SecretsManager?.baseServiceAccount || 0; + const usedServiceAccounts = sub?.smServiceAccounts || 0; + const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts; + return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0; + } +} diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 9a69755b209..7322f047551 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -12,10 +12,13 @@ import { BillingHistoryComponent } from "./billing-history.component"; import { OffboardingSurveyComponent } from "./offboarding-survey.component"; import { PaymentComponent } from "./payment/payment.component"; import { PaymentMethodComponent } from "./payment-method.component"; +import { PlanCardComponent } from "./plan-card/plan-card.component"; +import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component"; import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component"; import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component"; import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; import { TaxInfoComponent } from "./tax-info.component"; +import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component"; import { UpdateLicenseDialogComponent } from "./update-license-dialog.component"; import { UpdateLicenseComponent } from "./update-license.component"; import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component"; @@ -41,6 +44,9 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac AdjustStorageDialogComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + TrialPaymentDialogComponent, + PlanCardComponent, + PricingSummaryComponent, ], exports: [ SharedModule, diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html new file mode 100644 index 00000000000..08fd3b435f6 --- /dev/null +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html @@ -0,0 +1,45 @@ +@let isFocused = plan().isSelected; +@let isRecommended = plan().isAnnual; + +<bit-card + class="tw-h-full" + [ngClass]="getPlanCardContainerClasses()" + (click)="cardClicked.emit()" + [attr.tabindex]="!isFocused || plan().isDisabled ? '-1' : '0'" + [attr.data-selected]="plan()?.isSelected" +> + <div class="tw-relative"> + @if (isRecommended) { + <div + class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1" + [ngClass]="{ + 'tw-bg-primary-700 !tw-text-contrast': plan().isSelected, + 'tw-bg-secondary-100': !plan().isSelected, + }" + > + {{ "recommended" | i18n }} + </div> + } + <div + class="tw-px-2 tw-pb-[4px]" + [ngClass]="{ + 'tw-py-1': !plan().isSelected, + 'tw-py-0': plan().isSelected, + }" + > + <h3 + class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center" + > + <span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span> + <!-- Discount Badge --> + <span class="tw-mr-1 tw-ml-2" *ngIf="isRecommended" bitBadge variant="success"> + {{ "upgradeDiscount" | i18n: plan().discount }}</span + > + </h3> + <span> + <b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b> + <span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span> + </span> + </div> + </div> +</bit-card> diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts new file mode 100644 index 00000000000..9e3f03a5e7d --- /dev/null +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts @@ -0,0 +1,68 @@ +import { Component, input, output } from "@angular/core"; + +import { ProductTierType } from "@bitwarden/common/billing/enums"; + +export interface PlanCard { + title: string; + costPerMember: number; + discount?: number; + isDisabled: boolean; + isAnnual: boolean; + isSelected: boolean; + productTier: ProductTierType; +} + +@Component({ + selector: "app-plan-card", + templateUrl: "./plan-card.component.html", + standalone: false, +}) +export class PlanCardComponent { + plan = input.required<PlanCard>(); + productTiers = ProductTierType; + + cardClicked = output(); + + getPlanCardContainerClasses(): string[] { + const isSelected = this.plan().isSelected; + const isDisabled = this.plan().isDisabled; + if (isDisabled) { + return [ + "tw-cursor-not-allowed", + "tw-bg-secondary-100", + "tw-font-normal", + "tw-bg-blur", + "tw-text-muted", + "tw-block", + "tw-rounded", + ]; + } + + return isSelected + ? [ + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-primary-600", + "tw-border-2", + "tw-rounded-lg", + "hover:tw-border-primary-700", + "focus:tw-border-3", + "focus:tw-border-primary-700", + "focus:tw-rounded-lg", + ] + : [ + "tw-cursor-pointer", + "tw-block", + "tw-rounded", + "tw-border", + "tw-border-solid", + "tw-border-secondary-300", + "hover:tw-border-text-main", + "focus:tw-border-2", + "focus:tw-border-primary-700", + ]; + } +} diff --git a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html new file mode 100644 index 00000000000..855b83bdb2d --- /dev/null +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.html @@ -0,0 +1,259 @@ +<ng-container> + <div class="tw-mt-4"> + <p class="tw-text-lg tw-mb-1"> + <span class="tw-font-semibold" + >{{ "total" | i18n }}: + {{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD</span + > + <span class="tw-text-xs tw-font-light"> / {{ summaryData.selectedPlanInterval | i18n }}</span> + <button + (click)="toggleTotalOpened()" + type="button" + [bitIconButton]="summaryData.totalOpened ? 'bwi-angle-down' : 'bwi-angle-up'" + size="small" + aria-hidden="true" + ></button> + </p> + </div> + + <ng-container *ngIf="summaryData.totalOpened"> + <!-- Main content container --> + <div class="tw-flex tw-flex-wrap tw-gap-4"> + <bit-hint class="tw-w-full"> + <ng-container *ngIf="summaryData.isSecretsManagerTrial; else showPasswordManagerFirst"> + <ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container> + <ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container> + </ng-container> + + <ng-template #showPasswordManagerFirst> + <ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container> + <ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container> + </ng-template> + + <!-- Password Manager section --> + <ng-template #passwordManagerSection> + <ng-container + *ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager" + > + <p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p> + + <!-- Base Price --> + <ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice"> + <p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2"> + <span> + <ng-container [ngSwitch]="summaryData.selectedInterval"> + <ng-container *ngSwitchCase="planIntervals.Annually"> + {{ summaryData.passwordManagerSeats }} {{ "members" | i18n }} × + {{ + (summaryData.selectedPlan.isAnnual + ? summaryData.selectedPlan.PasswordManager.basePrice / 12 + : summaryData.selectedPlan.PasswordManager.basePrice + ) | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + </ng-container> + <ng-container *ngSwitchDefault> + {{ "basePrice" | i18n }}: + {{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + </ng-container> + </ng-container> + </span> + <span> + <ng-container + *ngIf="summaryData.acceptingSponsorship; else notAcceptingSponsorship" + > + <span class="tw-line-through">{{ + summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" + }}</span> + {{ "freeWithSponsorship" | i18n }} + </ng-container> + <ng-template #notAcceptingSponsorship> + {{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }} + </ng-template> + </span> + </p> + </ng-container> + + <!-- Additional Seats --> + <ng-container *ngIf="summaryData.selectedPlan.PasswordManager.hasAdditionalSeatsOption"> + <p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2"> + <span> + <span *ngIf="summaryData.selectedPlan.PasswordManager.baseSeats" + >{{ "additionalUsers" | i18n }}:</span + > + {{ summaryData.passwordManagerSeats || 0 }}  + <span *ngIf="!summaryData.selectedPlan.PasswordManager.baseSeats">{{ + "members" | i18n + }}</span> + × + {{ summaryData.selectedPlan.PasswordManager.seatPrice | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + </span> + <span *ngIf="!summaryData.isSecretsManagerTrial"> + {{ summaryData.passwordManagerSeatTotal | currency: "$" }} + </span> + <span *ngIf="summaryData.isSecretsManagerTrial"> + {{ "freeForOneYear" | i18n }} + </span> + </p> + </ng-container> + + <!-- Additional Storage --> + <ng-container + *ngIf=" + summaryData.selectedPlan.PasswordManager.hasAdditionalStorageOption && + summaryData.storageGb > 0 + " + > + <p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2"> + <span> + {{ summaryData.storageGb }} {{ "additionalStorageGbMessage" | i18n }} + × + {{ summaryData.additionalStoragePriceMonthly | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + </span> + <span> + <ng-container [ngSwitch]="summaryData.selectedInterval"> + <ng-container *ngSwitchCase="planIntervals.Annually"> + {{ summaryData.additionalStorageTotal | currency: "$" }} + </ng-container> + <ng-container *ngSwitchDefault> + {{ + summaryData.storageGb * + summaryData.selectedPlan.PasswordManager.additionalStoragePricePerGb + | currency: "$" + }} + </ng-container> + </ng-container> + </span> + </p> + </ng-container> + </ng-container> + </ng-template> + + <!-- Secrets Manager section --> + <ng-template #secretsManagerSection> + <ng-container *ngIf="summaryData.organization.useSecretsManager"> + <p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p> + + <!-- Base Price --> + <ng-container *ngIf="summaryData.selectedPlan?.SecretsManager?.basePrice"> + <p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2"> + <span> + <ng-container [ngSwitch]="summaryData.selectedInterval"> + <ng-container *ngSwitchCase="planIntervals.Annually"> + {{ summaryData.sub?.smSeats }} {{ "members" | i18n }} × + {{ + (summaryData.selectedPlan.isAnnual + ? summaryData.selectedPlan.SecretsManager.basePrice / 12 + : summaryData.selectedPlan.SecretsManager.basePrice + ) | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + </ng-container> + <ng-container *ngSwitchDefault> + {{ "basePrice" | i18n }}: + {{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + </ng-container> + </ng-container> + </span> + <span *ngIf="summaryData.selectedInterval === planIntervals.Monthly"> + {{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }} + </span> + </p> + </ng-container> + + <!-- Additional Seats --> + <ng-container + *ngIf="summaryData.selectedPlan?.SecretsManager?.hasAdditionalSeatsOption" + > + <p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2"> + <span> + <span *ngIf="summaryData.selectedPlan.SecretsManager.baseSeats" + >{{ "additionalUsers" | i18n }}:</span + > + {{ summaryData.sub?.smSeats || 0 }}  + <span *ngIf="!summaryData.selectedPlan.SecretsManager.baseSeats">{{ + "members" | i18n + }}</span> + × + {{ summaryData.selectedPlan.SecretsManager.seatPrice | currency: "$" }} + /{{ summaryData.selectedPlanInterval | i18n }} + </span> + <span> + {{ summaryData.secretsManagerSeatTotal | currency: "$" }} + </span> + </p> + </ng-container> + + <!-- Additional Service Accounts --> + <ng-container + *ngIf=" + summaryData.selectedPlan?.SecretsManager?.hasAdditionalServiceAccountOption && + summaryData.additionalServiceAccount > 0 + " + > + <p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2"> + <span> + {{ summaryData.additionalServiceAccount }} + {{ "serviceAccounts" | i18n | lowercase }} + × + {{ + summaryData.selectedPlan?.SecretsManager?.additionalPricePerServiceAccount + | currency: "$" + }} + /{{ summaryData.selectedPlanInterval | i18n }} + </span> + <span>{{ summaryData.additionalServiceAccountTotal | currency: "$" }}</span> + </p> + </ng-container> + </ng-container> + </ng-template> + + <!-- Discount Section --> + <ng-container *ngIf="summaryData.discountPercentageFromSub > 0"> + <p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2"> + <span class="tw-text-xs"> + {{ + "providerDiscount" | i18n: this.summaryData.discountPercentageFromSub | lowercase + }} + </span> + <span class="tw-line-through tw-text-xs"> + {{ summaryData.totalAppliedDiscount | currency: "$" }} + </span> + </p> + </ng-container> + </bit-hint> + </div> + + <!-- Tax and Total Section --> + <div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4"> + <bit-hint class="tw-w-full"> + <p + class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0" + > + <span class="tw-font-semibold">{{ "estimatedTax" | i18n }}</span> + <span>{{ summaryData.estimatedTax | currency: "USD" : "$" }}</span> + </p> + </bit-hint> + </div> + + <div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4"> + <bit-hint class="tw-w-full"> + <p + class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0" + > + <span class="tw-font-semibold">{{ "total" | i18n }}</span> + <span> + {{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} + <span class="tw-text-xs tw-font-semibold" + >/ {{ summaryData.selectedPlanInterval | i18n }}</span + > + </span> + </p> + </bit-hint> + </div> + </ng-container> +</ng-container> diff --git a/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts new file mode 100644 index 00000000000..d4fdf35b743 --- /dev/null +++ b/apps/web/src/app/billing/shared/pricing-summary/pricing-summary.component.ts @@ -0,0 +1,48 @@ +import { Component, Input } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlanInterval } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; + +export interface PricingSummaryData { + selectedPlanInterval: string; + passwordManagerSeats: number; + passwordManagerSeatTotal: number; + secretsManagerSeatTotal: number; + additionalStorageTotal: number; + additionalStoragePriceMonthly: number; + additionalServiceAccountTotal: number; + totalAppliedDiscount: number; + secretsManagerSubtotal: number; + passwordManagerSubtotal: number; + total: number; + organization?: Organization; + sub?: OrganizationSubscriptionResponse; + selectedPlan?: PlanResponse; + selectedInterval?: PlanInterval; + discountPercentageFromSub?: number; + discountPercentage?: number; + acceptingSponsorship?: boolean; + additionalServiceAccount?: number; + totalOpened?: boolean; + storageGb?: number; + isSecretsManagerTrial?: boolean; + estimatedTax?: number; +} + +@Component({ + selector: "app-pricing-summary", + templateUrl: "./pricing-summary.component.html", + standalone: false, +}) +export class PricingSummaryComponent { + @Input() summaryData!: PricingSummaryData; + planIntervals = PlanInterval; + + toggleTotalOpened(): void { + if (this.summaryData) { + this.summaryData.totalOpened = !this.summaryData.totalOpened; + } + } +} diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html new file mode 100644 index 00000000000..dbd2899c9e0 --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html @@ -0,0 +1,117 @@ +<bit-dialog dialogSize="default"> + <span bitDialogTitle class="tw-font-semibold"> + {{ "subscribetoEnterprise" | i18n: currentPlanName }} + </span> + + <div bitDialogContent> + <p>{{ "subscribeEnterpriseSubtitle" | i18n: currentPlanName }}</p> + + <!-- Plan Features List --> + <ng-container [ngSwitch]="currentPlan?.productTier"> + <ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Enterprise"> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "includeEnterprisePolicies" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "passwordLessSso" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "accountRecovery" | i18n }} + </li> + <li *ngIf="!organization?.canAccessSecretsManager"> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "customRoles" | i18n }} + </li> + <li *ngIf="organization?.canAccessSecretsManager"> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "unlimitedSecretsAndProjects" | i18n }} + </li> + </ul> + + <ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Teams"> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "secureDataSharing" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "eventLogMonitoring" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "directoryIntegration" | i18n }} + </li> + <li *ngIf="organization?.canAccessSecretsManager"> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "unlimitedSecretsAndProjects" | i18n }} + </li> + </ul> + + <ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Families"> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "premiumAccounts" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "unlimitedSharing" | i18n }} + </li> + <li> + <i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i> + {{ "createUnlimitedCollections" | i18n }} + </li> + </ul> + </ng-container> + + <div *ngIf="!(currentPlan?.productTier === productTypes.Families)"> + <div class="tw-mb-3 tw-flex tw-justify-between"> + <h4 class="tw-text-lg tw-text-main">{{ "selectAPlan" | i18n }}</h4> + </div> + + <ng-container *ngIf="planCards().length > 0"> + <div + class="tw-grid tw-grid-flow-col tw-gap-4 tw-mb-2" + [class]="'tw-grid-cols-' + planCards().length" + > + @for (planCard of planCards(); track $index) { + <app-plan-card [plan]="planCard" (cardClicked)="setSelected(planCard)"></app-plan-card> + } + </div> + </ng-container> + </div> + <!-- Payment Information --> + <ng-container> + <h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2> + <ng-container bitDialogContent> + <app-payment + [showAccountCredit]="false" + [showBankAccount]="!!organizationId" + [initialPaymentMethod]="initialPaymentMethod" + ></app-payment> + <app-manage-tax-information + *ngIf="taxInformation" + [showTaxIdField]="showTaxIdField" + [startWith]="taxInformation" + (taxInformationChanged)="taxInformationChanged($event)" + /> + </ng-container> + <!-- Pricing Breakdown --> + <app-pricing-summary + *ngIf="pricingSummaryData" + [summaryData]="pricingSummaryData" + ></app-pricing-summary> + </ng-container> + </div> + <!-- Dialog Footer --> + <ng-container bitDialogFooter> + <button bitButton buttonType="primary" type="button" [bitAction]="onSubscribe.bind(this)"> + {{ "subscribe" | i18n }} + </button> + <button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.CLOSED"> + {{ "later" | i18n }} + </button> + </ng-container> +</bit-dialog> diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts new file mode 100644 index 00000000000..ca51ae80e1f --- /dev/null +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts @@ -0,0 +1,365 @@ +import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; + +import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { + getOrganizationById, + 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; +import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain"; +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; +import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; + +import { PlanCardService } from "../../services/plan-card.service"; +import { PaymentComponent } from "../payment/payment.component"; +import { PlanCard } from "../plan-card/plan-card.component"; +import { PricingSummaryData } from "../pricing-summary/pricing-summary.component"; + +import { PricingSummaryService } from "./../../services/pricing-summary.service"; + +type TrialPaymentDialogParams = { + organizationId: string; + subscription: OrganizationSubscriptionResponse; + productTierType: ProductTierType; + initialPaymentMethod?: PaymentMethodType; +}; + +export const TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE = { + CLOSED: "closed", + SUBMITTED: "submitted", +} as const; + +export type TrialPaymentDialogResultType = + (typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE)[keyof typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE]; + +interface OnSuccessArgs { + organizationId: string; +} + +@Component({ + selector: "app-trial-payment-dialog", + templateUrl: "./trial-payment-dialog.component.html", + standalone: false, +}) +export class TrialPaymentDialogComponent implements OnInit { + @ViewChild(PaymentComponent) paymentComponent!: PaymentComponent; + @ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent; + + currentPlan!: PlanResponse; + currentPlanName!: string; + productTypes = ProductTierType; + organization!: Organization; + organizationId!: string; + sub!: OrganizationSubscriptionResponse; + selectedInterval: PlanInterval = PlanInterval.Annually; + + planCards = signal<PlanCard[]>([]); + plans!: ListResponse<PlanResponse>; + + @Output() onSuccess = new EventEmitter<OnSuccessArgs>(); + protected initialPaymentMethod: PaymentMethodType; + protected taxInformation!: TaxInformation; + protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE; + pricingSummaryData!: PricingSummaryData; + + constructor( + @Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams, + private dialogRef: DialogRef<TrialPaymentDialogResultType>, + private organizationService: OrganizationService, + private i18nService: I18nService, + private organizationApiService: OrganizationApiServiceAbstraction, + private accountService: AccountService, + private planCardService: PlanCardService, + private pricingSummaryService: PricingSummaryService, + private apiService: ApiService, + private toastService: ToastService, + private billingApiService: BillingApiServiceAbstraction, + private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, + ) { + this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; + } + + async ngOnInit(): Promise<void> { + this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType); + this.sub = + this.dialogParams.subscription ?? + (await this.organizationApiService.getSubscription(this.dialogParams.organizationId)); + this.organizationId = this.dialogParams.organizationId; + this.currentPlan = this.sub?.plan; + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + if (!userId) { + throw new Error("User ID is required"); + } + const organization = await firstValueFrom( + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.organizationId)), + ); + if (!organization) { + throw new Error("Organization not found"); + } + this.organization = organization; + + const planCards = await this.planCardService.getCadenceCards( + this.currentPlan, + this.sub, + this.isSecretsManagerTrial(), + ); + + this.planCards.set(planCards); + + if (!this.selectedInterval) { + this.selectedInterval = planCards.find((card) => card.isSelected)?.isAnnual + ? PlanInterval.Annually + : PlanInterval.Monthly; + } + + const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); + this.taxInformation = TaxInformation.from(taxInfo); + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + + this.plans = await this.apiService.getPlans(); + } + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig<TrialPaymentDialogParams>, + ) => dialogService.open<TrialPaymentDialogResultType>(TrialPaymentDialogComponent, dialogConfig); + + async setSelected(planCard: PlanCard) { + this.selectedInterval = planCard.isAnnual ? PlanInterval.Annually : PlanInterval.Monthly; + + this.planCards.update((planCards) => { + return planCards.map((planCard) => { + if (planCard.isSelected) { + return { + ...planCard, + isSelected: false, + }; + } else { + return { + ...planCard, + isSelected: true, + }; + } + }); + }); + + await this.selectPlan(); + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + } + + protected async selectPlan() { + if ( + this.selectedInterval === PlanInterval.Monthly && + this.currentPlan.productTier == ProductTierType.Families + ) { + return; + } + + const filteredPlans = this.plans.data.filter( + (plan) => + plan.productTier === this.currentPlan.productTier && + plan.isAnnual === (this.selectedInterval === PlanInterval.Annually), + ); + if (filteredPlans.length > 0) { + this.currentPlan = filteredPlans[0]; + } + try { + await this.refreshSalesTax(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const translatedMessage = this.i18nService.t(errorMessage); + this.toastService.showToast({ + title: "", + variant: "error", + message: !translatedMessage || translatedMessage === "" ? errorMessage : translatedMessage, + }); + } + } + + protected get showTaxIdField(): boolean { + switch (this.currentPlan.productTier) { + case ProductTierType.Free: + case ProductTierType.Families: + return false; + default: + return true; + } + } + + private async refreshSalesTax(): Promise<void> { + if ( + this.taxInformation === undefined || + !this.taxInformation.country || + !this.taxInformation.postalCode + ) { + return; + } + + const request: PreviewOrganizationInvoiceRequest = { + organizationId: this.organizationId, + passwordManager: { + additionalStorage: 0, + plan: this.currentPlan?.type, + seats: this.sub.seats, + }, + taxInformation: { + postalCode: this.taxInformation.postalCode, + country: this.taxInformation.country, + taxId: this.taxInformation.taxId, + }, + }; + + if (this.organization.useSecretsManager) { + request.secretsManager = { + seats: this.sub.smSeats ?? 0, + additionalMachineAccounts: + (this.sub.smServiceAccounts ?? 0) - + (this.sub.plan.SecretsManager?.baseServiceAccount ?? 0), + }; + } + + this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( + this.currentPlan, + this.sub, + this.organization, + this.selectedInterval, + this.taxInformation, + this.isSecretsManagerTrial(), + ); + } + + async taxInformationChanged(event: TaxInformation) { + this.taxInformation = event; + this.toggleBankAccount(); + await this.refreshSalesTax(); + } + + toggleBankAccount = () => { + this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; + + if ( + !this.paymentComponent.showBankAccount && + this.paymentComponent.selected === PaymentMethodType.BankAccount + ) { + this.paymentComponent.select(PaymentMethodType.Card); + } + }; + + isSecretsManagerTrial(): boolean { + return ( + this.sub?.subscription?.items?.some((item) => + this.sub?.customerDiscount?.appliesTo?.includes(item.productId), + ) ?? false + ); + } + + async onSubscribe(): Promise<void> { + if (!this.taxComponent.validate()) { + this.taxComponent.markAllAsTouched(); + } + try { + await this.updateOrganizationPaymentMethod( + this.organizationId, + this.paymentComponent, + this.taxInformation, + ); + + if (this.currentPlan.type !== this.sub.planType) { + const changePlanRequest = new ChangePlanFrequencyRequest(); + changePlanRequest.newPlanType = this.currentPlan.type; + await this.organizationBillingApiServiceAbstraction.changeSubscriptionFrequency( + this.organizationId, + changePlanRequest, + ); + } + + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("updatedPaymentMethod"), + }); + + this.onSuccess.emit({ organizationId: this.organizationId }); + this.dialogRef.close(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED); + } catch (error) { + const msg = + typeof error === "object" && error !== null && "message" in error + ? (error as { message: string }).message + : String(error); + this.toastService.showToast({ + variant: "error", + title: undefined, + message: this.i18nService.t(msg) || msg, + }); + } + } + + private async updateOrganizationPaymentMethod( + organizationId: string, + paymentComponent: PaymentComponent, + taxInformation: TaxInformation, + ): Promise<void> { + const paymentSource = await paymentComponent.tokenize(); + + const request = new UpdatePaymentMethodRequest(); + request.paymentSource = paymentSource; + request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation); + + await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request); + } + + resolvePlanName(productTier: ProductTierType): string { + switch (productTier) { + case ProductTierType.Enterprise: + return this.i18nService.t("planNameEnterprise"); + case ProductTierType.Free: + return this.i18nService.t("planNameFree"); + case ProductTierType.Families: + return this.i18nService.t("planNameFamilies"); + case ProductTierType.Teams: + return this.i18nService.t("planNameTeams"); + case ProductTierType.TeamsStarter: + return this.i18nService.t("planNameTeamsStarter"); + default: + return this.i18nService.t("planNameFree"); + } + } +} diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts index 074358537b6..a7ce53c9998 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts @@ -1,8 +1,10 @@ import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { Observable } from "rxjs"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Observable, Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -37,16 +39,28 @@ import { OrganizationFreeTrialWarning } from "../types"; `, imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], }) -export class OrganizationFreeTrialWarningComponent implements OnInit { +export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter<void>(); warning$!: Observable<OrganizationFreeTrialWarning>; + private destroy$ = new Subject<void>(); constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); + this.organizationWarningsService + .refreshWarningsForOrganization$(this.organization.id as OrganizationId) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.refresh(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } refresh = () => { diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts index fa53992afe0..78c17a5d384 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts @@ -1,6 +1,7 @@ +import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs"; +import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -10,10 +11,15 @@ import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/r import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, +} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; const format = (date: Date) => @@ -26,6 +32,7 @@ const format = (date: Date) => @Injectable({ providedIn: "root" }) export class OrganizationWarningsService { private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>(); + private refreshWarnings$ = new Subject<OrganizationId>(); constructor( private configService: ConfigService, @@ -34,6 +41,8 @@ export class OrganizationWarningsService { private organizationApiService: OrganizationApiServiceAbstraction, private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, private router: Router, + private location: Location, + protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( @@ -174,10 +183,33 @@ export class OrganizationWarningsService { }); break; } + case "add_payment_method_optional_trial": { + const organizationSubscriptionResponse = + await this.organizationApiService.getSubscription(organization.id); + + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + subscription: organizationSubscriptionResponse, + productTierType: organization?.productTierType, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { + this.refreshWarnings$.next(organization.id as OrganizationId); + } + } } }), ); + refreshWarningsForOrganization$(organizationId: OrganizationId): Observable<void> { + return this.refreshWarnings$.pipe( + filter((id) => id === organizationId), + map((): void => void 0), + ); + } + private getResponse$ = ( organization: Organization, bypassCache: boolean = false, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d5ded3c75ea..9c9ecc79721 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -568,6 +568,9 @@ "cancel": { "message": "Cancel" }, + "later": { + "message": "Later" + }, "canceled": { "message": "Canceled" }, @@ -4630,6 +4633,9 @@ "receiveMarketingEmailsV2": { "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." }, + "subscribe": { + "message": "Subscribe" + }, "unsubscribe": { "message": "Unsubscribe" }, @@ -10900,5 +10906,26 @@ "example": "12-3456789" } } + }, + "subscribetoEnterprise": { + "message": "Subscribe to $PLAN$", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "subscribeEnterpriseSubtitle": { + "message": "Your 7-day $PLAN$ trial starts today. Add a payment method now to continue using these features after your trial ends: ", + "placeholders": { + "plan": { + "content": "$1", + "example": "Teams" + } + } + }, + "unlimitedSecretsAndProjects": { + "message": "Unlimited secrets and projects" } -} +} \ No newline at end of file diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index 4975da0d7d2..29301e626b9 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,3 +1,4 @@ +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { @@ -28,4 +29,9 @@ export abstract class OrganizationBillingApiServiceAbstraction { organizationKey: string; }, ) => Promise<string>; + + abstract changeSubscriptionFrequency: ( + organizationId: string, + request: ChangePlanFrequencyRequest, + ) => Promise<void>; } diff --git a/libs/common/src/billing/models/request/change-plan-frequency.request.ts b/libs/common/src/billing/models/request/change-plan-frequency.request.ts new file mode 100644 index 00000000000..70b77181663 --- /dev/null +++ b/libs/common/src/billing/models/request/change-plan-frequency.request.ts @@ -0,0 +1,9 @@ +import { PlanType } from "../../enums"; + +export class ChangePlanFrequencyRequest { + newPlanType: PlanType; + + constructor(newPlanType?: PlanType) { + this.newPlanType = newPlanType!; + } +} diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index 1189316a487..e9456f61026 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -1,3 +1,4 @@ +import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { ApiService } from "../../../abstractions/api.service"; @@ -83,4 +84,17 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ return response as string; } + + async changeSubscriptionFrequency( + organizationId: string, + request: ChangePlanFrequencyRequest, + ): Promise<void> { + return await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/billing/change-frequency", + request, + true, + false, + ); + } } From da6fb82fd8ed68a910f2558373d0e5cfcdbc0162 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:21:19 -0500 Subject: [PATCH 38/54] [deps] AC: Update core-js to v3.44.0 (#15284) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index ca7af3ec596..54855d72104 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -69,7 +69,7 @@ "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.42.0", + "core-js": "3.44.0", "form-data": "4.0.2", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", diff --git a/package-lock.json b/package-lock.json index e44797997f1..01a9ea8c09c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,7 @@ "bufferutil": "4.0.9", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.42.0", + "core-js": "3.44.0", "form-data": "4.0.2", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -205,7 +205,7 @@ "browser-hrtime": "1.1.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.42.0", + "core-js": "3.44.0", "form-data": "4.0.2", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", @@ -17512,9 +17512,9 @@ } }, "node_modules/core-js": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.42.0.tgz", - "integrity": "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", + "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", "hasInstallScript": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index 089ef3342e9..2cb60a6afd1 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "bufferutil": "4.0.9", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.42.0", + "core-js": "3.44.0", "form-data": "4.0.2", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", From e99abb49ec26e983fa76b2bb114d6c330d0a9ce6 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:30:50 -0500 Subject: [PATCH 39/54] [PM-23621] Require userId for initAccount on the key-service (#15684) * require userID for initAccount on key service * add unit test coverage * update consumer --- .../login-decryption-options.component.ts | 2 +- .../src/abstractions/key.service.ts | 7 +- libs/key-management/src/key.service.spec.ts | 101 ++++++++++++++++++ libs/key-management/src/key.service.ts | 14 ++- 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index bbdc0106786..a2018817fed 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -249,7 +249,7 @@ export class LoginDecryptionOptionsComponent implements OnInit { } try { - const { publicKey, privateKey } = await this.keyService.initAccount(); + const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId); const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); await this.apiService.postAccountKeys(keysRequest); diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index c843d8dc872..3c0d6c8a138 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -386,11 +386,12 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key - * - * @throws An error if there is no user currently active. + * @throws An error if the userId is null or undefined. + * @throws An error if the user already has a user key. */ - abstract initAccount(): Promise<{ + abstract initAccount(userId: UserId): Promise<{ userKey: UserKey; publicKey: string; privateKey: EncString; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 395d668de9f..7a033792c79 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1047,4 +1047,105 @@ describe("keyService", () => { }); }); }); + + describe("initAccount", () => { + let userKey: UserKey; + let mockPublicKey: string; + let mockPrivateKey: EncString; + + beforeEach(() => { + userKey = makeSymmetricCryptoKey<UserKey>(64); + mockPublicKey = "mockPublicKey"; + mockPrivateKey = makeEncString("mockPrivateKey"); + + keyGenerationService.createKey.mockResolvedValue(userKey); + jest.spyOn(keyService, "makeKeyPair").mockResolvedValue([mockPublicKey, mockPrivateKey]); + jest.spyOn(keyService, "setUserKey").mockResolvedValue(); + }); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + await expect(keyService.initAccount(userId)).rejects.toThrow("UserId is required."); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + it("throws when user already has a user key", async () => { + const existingUserKey = makeSymmetricCryptoKey<UserKey>(64); + stateProvider.singleUser.getFake(mockUserId, USER_KEY).nextState(existingUserKey); + + await expect(keyService.initAccount(mockUserId)).rejects.toThrow( + "Cannot initialize account, keys already exist.", + ); + expect(logService.error).toHaveBeenCalledWith( + "Tried to initialize account with existing user key.", + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("throws when private key creation fails", async () => { + // Simulate failure + const invalidPrivateKey = new EncString( + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=", + ); + invalidPrivateKey.encryptedString = null as unknown as EncryptedString; + jest.spyOn(keyService, "makeKeyPair").mockResolvedValue([mockPublicKey, invalidPrivateKey]); + + await expect(keyService.initAccount(mockUserId)).rejects.toThrow( + "Failed to create valid private key.", + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("successfully initializes account with new keys", async () => { + const keyCreationSize = 512; + const privateKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_ENCRYPTED_PRIVATE_KEY, + ); + + const result = await keyService.initAccount(mockUserId); + + expect(keyGenerationService.createKey).toHaveBeenCalledWith(keyCreationSize); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(userKey); + expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, mockUserId); + expect(privateKeyState.nextMock).toHaveBeenCalledWith(mockPrivateKey.encryptedString); + expect(result).toEqual({ + userKey: userKey, + publicKey: mockPublicKey, + privateKey: mockPrivateKey, + }); + }); + }); + + describe("makeKeyPair", () => { + test.each([null as unknown as SymmetricCryptoKey, undefined as unknown as SymmetricCryptoKey])( + "throws when the provided key is %s", + async (key) => { + await expect(keyService.makeKeyPair(key)).rejects.toThrow( + "'key' is a required parameter and must be non-null.", + ); + }, + ); + + it("generates a key pair and returns public key and encrypted private key", async () => { + const mockKey = new SymmetricCryptoKey(new Uint8Array(64)); + const mockKeyPair: [Uint8Array, Uint8Array] = [new Uint8Array(256), new Uint8Array(256)]; + const mockPublicKeyB64 = "mockPublicKeyB64"; + const mockPrivateKeyEncString = makeEncString("encryptedPrivateKey"); + + cryptoFunctionService.rsaGenerateKeyPair.mockResolvedValue(mockKeyPair); + jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(mockPublicKeyB64); + encryptService.wrapDecapsulationKey.mockResolvedValue(mockPrivateKeyEncString); + + const [publicKey, privateKey] = await keyService.makeKeyPair(mockKey); + + expect(cryptoFunctionService.rsaGenerateKeyPair).toHaveBeenCalledWith(2048); + expect(Utils.fromBufferToB64).toHaveBeenCalledWith(mockKeyPair[0]); + expect(encryptService.wrapDecapsulationKey).toHaveBeenCalledWith(mockKeyPair[1], mockKey); + expect(publicKey).toBe(mockPublicKeyB64); + expect(privateKey).toBe(mockPrivateKeyEncString); + }); + }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index fca080252f6..0f4b101d9b2 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -661,19 +661,17 @@ export class DefaultKeyService implements KeyServiceAbstraction { * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! */ - async initAccount(): Promise<{ + async initAccount(userId: UserId): Promise<{ userKey: UserKey; publicKey: string; privateKey: EncString; }> { - const activeUserId = await firstValueFrom(this.stateProvider.activeUserId$); - - if (activeUserId == null) { - throw new Error("Cannot initilize an account if one is not active."); + if (userId == null) { + throw new Error("UserId is required."); } // Verify user key doesn't exist - const existingUserKey = await this.getUserKey(activeUserId); + const existingUserKey = await this.getUserKey(userId); if (existingUserKey != null) { this.logService.error("Tried to initialize account with existing user key."); @@ -686,9 +684,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { throw new Error("Failed to create valid private key."); } - await this.setUserKey(userKey, activeUserId); + await this.setUserKey(userKey, userId); await this.stateProvider - .getUser(activeUserId, USER_ENCRYPTED_PRIVATE_KEY) + .getUser(userId, USER_ENCRYPTED_PRIVATE_KEY) .update(() => privateKey.encryptedString!); return { From a563e6d91000f453f5cbd8f904f397dccc96d058 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:47:25 -0400 Subject: [PATCH 40/54] Add `messaging` & `messaging-internal` libraries (#15711) --- .github/CODEOWNERS | 2 + .../sync/sync-service.listener.spec.ts | 4 +- .../abstractions/messaging.service.ts | 2 +- libs/common/src/platform/messaging/index.ts | 5 +- .../common/src/platform/messaging/internal.ts | 6 +- .../messaging/subject-message.sender.spec.ts | 65 ------------------- libs/messaging-internal/README.md | 5 ++ libs/messaging-internal/eslint.config.mjs | 3 + libs/messaging-internal/jest.config.js | 10 +++ libs/messaging-internal/package.json | 11 ++++ libs/messaging-internal/project.json | 33 ++++++++++ .../src}/helpers.spec.ts | 5 +- .../src}/helpers.ts | 8 +-- libs/messaging-internal/src/index.ts | 5 ++ .../src/messaging-internal.spec.ts | 8 +++ .../src/subject-message.sender.spec.ts | 59 +++++++++++++++++ .../src}/subject-message.sender.ts | 6 +- libs/messaging-internal/tsconfig.eslint.json | 6 ++ libs/messaging-internal/tsconfig.json | 13 ++++ libs/messaging-internal/tsconfig.lib.json | 10 +++ libs/messaging-internal/tsconfig.spec.json | 10 +++ libs/messaging/README.md | 5 ++ libs/messaging/eslint.config.mjs | 3 + libs/messaging/jest.config.js | 10 +++ libs/messaging/package.json | 11 ++++ libs/messaging/project.json | 33 ++++++++++ libs/messaging/src/index.ts | 4 ++ libs/messaging/src/is-external-message.ts | 5 ++ .../src}/message.listener.spec.ts | 26 ++++---- .../src}/message.listener.ts | 0 .../src}/message.sender.ts | 0 libs/messaging/src/messaging.spec.ts | 8 +++ .../messaging => messaging/src}/types.ts | 0 libs/messaging/tsconfig.eslint.json | 6 ++ libs/messaging/tsconfig.json | 13 ++++ libs/messaging/tsconfig.lib.json | 16 +++++ libs/messaging/tsconfig.spec.json | 17 +++++ package-lock.json | 17 +++++ tsconfig.base.json | 2 + 39 files changed, 347 insertions(+), 105 deletions(-) delete mode 100644 libs/common/src/platform/messaging/subject-message.sender.spec.ts create mode 100644 libs/messaging-internal/README.md create mode 100644 libs/messaging-internal/eslint.config.mjs create mode 100644 libs/messaging-internal/jest.config.js create mode 100644 libs/messaging-internal/package.json create mode 100644 libs/messaging-internal/project.json rename libs/{common/src/platform/messaging => messaging-internal/src}/helpers.spec.ts (90%) rename libs/{common/src/platform/messaging => messaging-internal/src}/helpers.ts (65%) create mode 100644 libs/messaging-internal/src/index.ts create mode 100644 libs/messaging-internal/src/messaging-internal.spec.ts create mode 100644 libs/messaging-internal/src/subject-message.sender.spec.ts rename libs/{common/src/platform/messaging => messaging-internal/src}/subject-message.sender.ts (77%) create mode 100644 libs/messaging-internal/tsconfig.eslint.json create mode 100644 libs/messaging-internal/tsconfig.json create mode 100644 libs/messaging-internal/tsconfig.lib.json create mode 100644 libs/messaging-internal/tsconfig.spec.json create mode 100644 libs/messaging/README.md create mode 100644 libs/messaging/eslint.config.mjs create mode 100644 libs/messaging/jest.config.js create mode 100644 libs/messaging/package.json create mode 100644 libs/messaging/project.json create mode 100644 libs/messaging/src/index.ts create mode 100644 libs/messaging/src/is-external-message.ts rename libs/{common/src/platform/messaging => messaging/src}/message.listener.spec.ts (55%) rename libs/{common/src/platform/messaging => messaging/src}/message.listener.ts (100%) rename libs/{common/src/platform/messaging => messaging/src}/message.sender.ts (100%) create mode 100644 libs/messaging/src/messaging.spec.ts rename libs/{common/src/platform/messaging => messaging/src}/types.ts (100%) create mode 100644 libs/messaging/tsconfig.eslint.json create mode 100644 libs/messaging/tsconfig.json create mode 100644 libs/messaging/tsconfig.lib.json create mode 100644 libs/messaging/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7d7fec2a5ea..203c7ae7607 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -94,6 +94,8 @@ libs/platform @bitwarden/team-platform-dev libs/storage-core @bitwarden/team-platform-dev libs/logging @bitwarden/team-platform-dev libs/storage-test-utils @bitwarden/team-platform-dev +libs/messaging @bitwarden/team-platform-dev +libs/messaging-internal @bitwarden/team-platform-dev # Web utils used across app and connectors apps/web/src/utils/ @bitwarden/team-platform-dev # Web core and shared files diff --git a/apps/browser/src/platform/sync/sync-service.listener.spec.ts b/apps/browser/src/platform/sync/sync-service.listener.spec.ts index dc0674a7ae5..383586c0cd0 100644 --- a/apps/browser/src/platform/sync/sync-service.listener.spec.ts +++ b/apps/browser/src/platform/sync/sync-service.listener.spec.ts @@ -3,10 +3,8 @@ import { Subject, firstValueFrom } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { tagAsExternal } from "@bitwarden/common/platform/messaging/helpers"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { tagAsExternal } from "@bitwarden/messaging-internal"; import { FullSyncMessage } from "./foreground-sync.service"; import { FULL_SYNC_FINISHED, SyncServiceListener } from "./sync-service.listener"; diff --git a/libs/common/src/platform/abstractions/messaging.service.ts b/libs/common/src/platform/abstractions/messaging.service.ts index f24279f932a..3520d9352ef 100644 --- a/libs/common/src/platform/abstractions/messaging.service.ts +++ b/libs/common/src/platform/abstractions/messaging.service.ts @@ -1,3 +1,3 @@ // Export the new message sender as the legacy MessagingService to minimize changes in the initial PR, // team specific PR's will come after. -export { MessageSender as MessagingService } from "../messaging/message.sender"; +export { MessageSender as MessagingService } from "@bitwarden/messaging"; diff --git a/libs/common/src/platform/messaging/index.ts b/libs/common/src/platform/messaging/index.ts index a9b4eca5ae8..5d452f32b31 100644 --- a/libs/common/src/platform/messaging/index.ts +++ b/libs/common/src/platform/messaging/index.ts @@ -1,4 +1 @@ -export { MessageListener } from "./message.listener"; -export { MessageSender } from "./message.sender"; -export { Message, CommandDefinition } from "./types"; -export { isExternalMessage } from "./helpers"; +export * from "@bitwarden/messaging"; diff --git a/libs/common/src/platform/messaging/internal.ts b/libs/common/src/platform/messaging/internal.ts index 08763d48bc5..9fe261f2264 100644 --- a/libs/common/src/platform/messaging/internal.ts +++ b/libs/common/src/platform/messaging/internal.ts @@ -1,5 +1 @@ -// Built in implementations -export { SubjectMessageSender } from "./subject-message.sender"; - -// Helpers meant to be used only by other implementations -export { tagAsExternal, getCommand } from "./helpers"; +export * from "@bitwarden/messaging-internal"; diff --git a/libs/common/src/platform/messaging/subject-message.sender.spec.ts b/libs/common/src/platform/messaging/subject-message.sender.spec.ts deleted file mode 100644 index 4278fca7bc1..00000000000 --- a/libs/common/src/platform/messaging/subject-message.sender.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Subject } from "rxjs"; - -import { subscribeTo } from "../../../spec/observable-tracker"; - -import { SubjectMessageSender } from "./internal"; -import { MessageSender } from "./message.sender"; -import { Message, CommandDefinition } from "./types"; - -describe("SubjectMessageSender", () => { - const subject = new Subject<Message<{ test: number }>>(); - const subjectObservable = subject.asObservable(); - - const sut: MessageSender = new SubjectMessageSender(subject); - - describe("send", () => { - it("will send message with command from message definition", async () => { - const commandDefinition = new CommandDefinition<{ test: number }>("myCommand"); - - const tracker = subscribeTo(subjectObservable); - const pausePromise = tracker.pauseUntilReceived(1); - - sut.send(commandDefinition, { test: 1 }); - - await pausePromise; - - expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 }); - }); - - it("will send message with command from normal string", async () => { - const tracker = subscribeTo(subjectObservable); - const pausePromise = tracker.pauseUntilReceived(1); - - sut.send("myCommand", { test: 1 }); - - await pausePromise; - - expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 }); - }); - - it("will send message with object even if payload not given", async () => { - const tracker = subscribeTo(subjectObservable); - const pausePromise = tracker.pauseUntilReceived(1); - - sut.send("myCommand"); - - await pausePromise; - - expect(tracker.emissions[0]).toEqual({ command: "myCommand" }); - }); - - it.each([null, undefined])( - "will send message with object even if payload is null-ish (%s)", - async (payloadValue) => { - const tracker = subscribeTo(subjectObservable); - const pausePromise = tracker.pauseUntilReceived(1); - - sut.send("myCommand", payloadValue); - - await pausePromise; - - expect(tracker.emissions[0]).toEqual({ command: "myCommand" }); - }, - ); - }); -}); diff --git a/libs/messaging-internal/README.md b/libs/messaging-internal/README.md new file mode 100644 index 00000000000..a2f36138ad7 --- /dev/null +++ b/libs/messaging-internal/README.md @@ -0,0 +1,5 @@ +# messaging-internal + +Owned by: platform + +Internal details to accompany @bitwarden/messaging this library should not be consumed in non-platform code. diff --git a/libs/messaging-internal/eslint.config.mjs b/libs/messaging-internal/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/messaging-internal/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/messaging-internal/jest.config.js b/libs/messaging-internal/jest.config.js new file mode 100644 index 00000000000..152244f6603 --- /dev/null +++ b/libs/messaging-internal/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "messaging-internal", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/messaging-internal", +}; diff --git a/libs/messaging-internal/package.json b/libs/messaging-internal/package.json new file mode 100644 index 00000000000..7a0a13d2d67 --- /dev/null +++ b/libs/messaging-internal/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/messaging-internal", + "version": "0.0.1", + "description": "Internal details to accompany @bitwarden/messaging this library should not be consumed in non-platform code.", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/messaging-internal/project.json b/libs/messaging-internal/project.json new file mode 100644 index 00000000000..ad55cde5c20 --- /dev/null +++ b/libs/messaging-internal/project.json @@ -0,0 +1,33 @@ +{ + "name": "messaging-internal", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/messaging-internal/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/messaging-internal", + "main": "libs/messaging-internal/src/index.ts", + "tsConfig": "libs/messaging-internal/tsconfig.lib.json", + "assets": ["libs/messaging-internal/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/messaging-internal/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/messaging-internal/jest.config.js" + } + } + } +} diff --git a/libs/common/src/platform/messaging/helpers.spec.ts b/libs/messaging-internal/src/helpers.spec.ts similarity index 90% rename from libs/common/src/platform/messaging/helpers.spec.ts rename to libs/messaging-internal/src/helpers.spec.ts index 8839a542ffc..5a97ff959cc 100644 --- a/libs/common/src/platform/messaging/helpers.spec.ts +++ b/libs/messaging-internal/src/helpers.spec.ts @@ -1,7 +1,8 @@ import { Subject, firstValueFrom } from "rxjs"; -import { getCommand, isExternalMessage, tagAsExternal } from "./helpers"; -import { Message, CommandDefinition } from "./types"; +import { CommandDefinition, isExternalMessage, Message } from "@bitwarden/messaging"; + +import { getCommand, tagAsExternal } from "./helpers"; describe("helpers", () => { describe("getCommand", () => { diff --git a/libs/common/src/platform/messaging/helpers.ts b/libs/messaging-internal/src/helpers.ts similarity index 65% rename from libs/common/src/platform/messaging/helpers.ts rename to libs/messaging-internal/src/helpers.ts index e7521ea42a2..00231b455b7 100644 --- a/libs/common/src/platform/messaging/helpers.ts +++ b/libs/messaging-internal/src/helpers.ts @@ -1,6 +1,6 @@ import { map } from "rxjs"; -import { CommandDefinition } from "./types"; +import { CommandDefinition, EXTERNAL_SOURCE_TAG } from "@bitwarden/messaging"; export const getCommand = ( commandDefinition: CommandDefinition<Record<string, unknown>> | string, @@ -12,12 +12,6 @@ export const getCommand = ( } }; -export const EXTERNAL_SOURCE_TAG = Symbol("externalSource"); - -export const isExternalMessage = (message: Record<PropertyKey, unknown>) => { - return message?.[EXTERNAL_SOURCE_TAG] === true; -}; - export const tagAsExternal = <T extends Record<PropertyKey, unknown>>() => { return map((message: T) => { return Object.assign(message, { [EXTERNAL_SOURCE_TAG]: true }); diff --git a/libs/messaging-internal/src/index.ts b/libs/messaging-internal/src/index.ts new file mode 100644 index 00000000000..08763d48bc5 --- /dev/null +++ b/libs/messaging-internal/src/index.ts @@ -0,0 +1,5 @@ +// Built in implementations +export { SubjectMessageSender } from "./subject-message.sender"; + +// Helpers meant to be used only by other implementations +export { tagAsExternal, getCommand } from "./helpers"; diff --git a/libs/messaging-internal/src/messaging-internal.spec.ts b/libs/messaging-internal/src/messaging-internal.spec.ts new file mode 100644 index 00000000000..b2b50a218bd --- /dev/null +++ b/libs/messaging-internal/src/messaging-internal.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("messaging-internal", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/messaging-internal/src/subject-message.sender.spec.ts b/libs/messaging-internal/src/subject-message.sender.spec.ts new file mode 100644 index 00000000000..e3e5305d1b2 --- /dev/null +++ b/libs/messaging-internal/src/subject-message.sender.spec.ts @@ -0,0 +1,59 @@ +import { bufferCount, firstValueFrom, Subject } from "rxjs"; + +import { CommandDefinition, Message } from "@bitwarden/messaging"; + +import { SubjectMessageSender } from "./subject-message.sender"; + +describe("SubjectMessageSender", () => { + const subject = new Subject<Message<{ test: number }>>(); + const subjectObservable = subject.asObservable(); + + const sut = new SubjectMessageSender(subject); + + describe("send", () => { + it("will send message with command from message definition", async () => { + const commandDefinition = new CommandDefinition<{ test: number }>("myCommand"); + + const emissionsPromise = firstValueFrom(subjectObservable.pipe(bufferCount(1))); + + sut.send(commandDefinition, { test: 1 }); + + const emissions = await emissionsPromise; + + expect(emissions[0]).toEqual({ command: "myCommand", test: 1 }); + }); + + it("will send message with command from normal string", async () => { + const emissionsPromise = firstValueFrom(subjectObservable.pipe(bufferCount(1))); + + sut.send("myCommand", { test: 1 }); + + const emissions = await emissionsPromise; + + expect(emissions[0]).toEqual({ command: "myCommand", test: 1 }); + }); + + it("will send message with object even if payload not given", async () => { + const emissionsPromise = firstValueFrom(subjectObservable.pipe(bufferCount(1))); + + sut.send("myCommand"); + + const emissions = await emissionsPromise; + + expect(emissions[0]).toEqual({ command: "myCommand" }); + }); + + it.each([null, undefined])( + "will send message with object even if payload is null-ish (%s)", + async (payloadValue) => { + const emissionsPromise = firstValueFrom(subjectObservable.pipe(bufferCount(1))); + + sut.send("myCommand", payloadValue); + + const emissions = await emissionsPromise; + + expect(emissions[0]).toEqual({ command: "myCommand" }); + }, + ); + }); +}); diff --git a/libs/common/src/platform/messaging/subject-message.sender.ts b/libs/messaging-internal/src/subject-message.sender.ts similarity index 77% rename from libs/common/src/platform/messaging/subject-message.sender.ts rename to libs/messaging-internal/src/subject-message.sender.ts index 170f8a24c6f..e8df5913b01 100644 --- a/libs/common/src/platform/messaging/subject-message.sender.ts +++ b/libs/messaging-internal/src/subject-message.sender.ts @@ -1,8 +1,8 @@ import { Subject } from "rxjs"; -import { getCommand } from "./internal"; -import { MessageSender } from "./message.sender"; -import { Message, CommandDefinition } from "./types"; +import { CommandDefinition, Message, MessageSender } from "@bitwarden/messaging"; + +import { getCommand } from "./helpers"; export class SubjectMessageSender implements MessageSender { constructor(private readonly messagesSubject: Subject<Message<Record<string, unknown>>>) {} diff --git a/libs/messaging-internal/tsconfig.eslint.json b/libs/messaging-internal/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/messaging-internal/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/messaging-internal/tsconfig.json b/libs/messaging-internal/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/messaging-internal/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/messaging-internal/tsconfig.lib.json b/libs/messaging-internal/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/messaging-internal/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/messaging-internal/tsconfig.spec.json b/libs/messaging-internal/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/messaging-internal/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/messaging/README.md b/libs/messaging/README.md new file mode 100644 index 00000000000..98eb96a5a40 --- /dev/null +++ b/libs/messaging/README.md @@ -0,0 +1,5 @@ +# messaging + +Owned by: platform + +Services for sending and recieving messages from different contexts of the same application. diff --git a/libs/messaging/eslint.config.mjs b/libs/messaging/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/messaging/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/messaging/jest.config.js b/libs/messaging/jest.config.js new file mode 100644 index 00000000000..f0450499e31 --- /dev/null +++ b/libs/messaging/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "messaging", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/messaging", +}; diff --git a/libs/messaging/package.json b/libs/messaging/package.json new file mode 100644 index 00000000000..01c8d7cb0e7 --- /dev/null +++ b/libs/messaging/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/messaging", + "version": "0.0.1", + "description": "Services for sending and recieving messages from different contexts of the same application.", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/messaging/project.json b/libs/messaging/project.json new file mode 100644 index 00000000000..f00e0bd2dc9 --- /dev/null +++ b/libs/messaging/project.json @@ -0,0 +1,33 @@ +{ + "name": "messaging", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/messaging/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/messaging", + "main": "libs/messaging/src/index.ts", + "tsConfig": "libs/messaging/tsconfig.lib.json", + "assets": ["libs/messaging/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/messaging/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/messaging/jest.config.js" + } + } + } +} diff --git a/libs/messaging/src/index.ts b/libs/messaging/src/index.ts new file mode 100644 index 00000000000..9090ff581c1 --- /dev/null +++ b/libs/messaging/src/index.ts @@ -0,0 +1,4 @@ +export { MessageListener } from "./message.listener"; +export { MessageSender } from "./message.sender"; +export { Message, CommandDefinition } from "./types"; +export { isExternalMessage, EXTERNAL_SOURCE_TAG } from "./is-external-message"; diff --git a/libs/messaging/src/is-external-message.ts b/libs/messaging/src/is-external-message.ts new file mode 100644 index 00000000000..46775cb14d6 --- /dev/null +++ b/libs/messaging/src/is-external-message.ts @@ -0,0 +1,5 @@ +export const EXTERNAL_SOURCE_TAG = Symbol("externalSource"); + +export const isExternalMessage = (message: Record<PropertyKey, unknown>) => { + return message?.[EXTERNAL_SOURCE_TAG] === true; +}; diff --git a/libs/common/src/platform/messaging/message.listener.spec.ts b/libs/messaging/src/message.listener.spec.ts similarity index 55% rename from libs/common/src/platform/messaging/message.listener.spec.ts rename to libs/messaging/src/message.listener.spec.ts index 98bbf1fdc82..19787c6feae 100644 --- a/libs/common/src/platform/messaging/message.listener.spec.ts +++ b/libs/messaging/src/message.listener.spec.ts @@ -1,6 +1,4 @@ -import { Subject } from "rxjs"; - -import { subscribeTo } from "../../../spec/observable-tracker"; +import { bufferCount, firstValueFrom, Subject } from "rxjs"; import { MessageListener } from "./message.listener"; import { Message, CommandDefinition } from "./types"; @@ -13,35 +11,33 @@ describe("MessageListener", () => { describe("allMessages$", () => { it("runs on all nexts", async () => { - const tracker = subscribeTo(sut.allMessages$); - - const pausePromise = tracker.pauseUntilReceived(2); + const emissionsPromise = firstValueFrom(sut.allMessages$.pipe(bufferCount(2))); subject.next({ command: "command1", test: 1 }); subject.next({ command: "command2", test: 2 }); - await pausePromise; + const emissions = await emissionsPromise; - expect(tracker.emissions[0]).toEqual({ command: "command1", test: 1 }); - expect(tracker.emissions[1]).toEqual({ command: "command2", test: 2 }); + expect(emissions[0]).toEqual({ command: "command1", test: 1 }); + expect(emissions[1]).toEqual({ command: "command2", test: 2 }); }); }); describe("messages$", () => { it("runs on only my commands", async () => { - const tracker = subscribeTo(sut.messages$(testCommandDefinition)); - - const pausePromise = tracker.pauseUntilReceived(2); + const emissionsPromise = firstValueFrom( + sut.messages$(testCommandDefinition).pipe(bufferCount(2)), + ); subject.next({ command: "notMyCommand", test: 1 }); subject.next({ command: "myCommand", test: 2 }); subject.next({ command: "myCommand", test: 3 }); subject.next({ command: "notMyCommand", test: 4 }); - await pausePromise; + const emissions = await emissionsPromise; - expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 2 }); - expect(tracker.emissions[1]).toEqual({ command: "myCommand", test: 3 }); + expect(emissions[0]).toEqual({ command: "myCommand", test: 2 }); + expect(emissions[1]).toEqual({ command: "myCommand", test: 3 }); }); }); }); diff --git a/libs/common/src/platform/messaging/message.listener.ts b/libs/messaging/src/message.listener.ts similarity index 100% rename from libs/common/src/platform/messaging/message.listener.ts rename to libs/messaging/src/message.listener.ts diff --git a/libs/common/src/platform/messaging/message.sender.ts b/libs/messaging/src/message.sender.ts similarity index 100% rename from libs/common/src/platform/messaging/message.sender.ts rename to libs/messaging/src/message.sender.ts diff --git a/libs/messaging/src/messaging.spec.ts b/libs/messaging/src/messaging.spec.ts new file mode 100644 index 00000000000..170b24750c5 --- /dev/null +++ b/libs/messaging/src/messaging.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("messaging", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/common/src/platform/messaging/types.ts b/libs/messaging/src/types.ts similarity index 100% rename from libs/common/src/platform/messaging/types.ts rename to libs/messaging/src/types.ts diff --git a/libs/messaging/tsconfig.eslint.json b/libs/messaging/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/messaging/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/messaging/tsconfig.json b/libs/messaging/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/messaging/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/messaging/tsconfig.lib.json b/libs/messaging/tsconfig.lib.json new file mode 100644 index 00000000000..1f3b89d988e --- /dev/null +++ b/libs/messaging/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "../messaging-internal/src/subject-message.sender.spec.ts", + "../messaging-internal/src/subject-message.sender.ts", + "../messaging-internal/src/helpers.spec.ts", + "../messaging-internal/src/helpers.ts" + ], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/messaging/tsconfig.spec.json b/libs/messaging/tsconfig.spec.json new file mode 100644 index 00000000000..2e5b192faff --- /dev/null +++ b/libs/messaging/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts", + "../messaging-internal/src/subject-message.sender.spec.ts", + "../messaging-internal/src/helpers.spec.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 01a9ea8c09c..fbbc4c25b44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -352,6 +352,15 @@ "version": "0.0.1", "license": "GPL-3.0" }, + "libs/messaging": { + "name": "@bitwarden/messaging", + "version": "0.0.1", + "license": "GPL-3.0" + }, + "libs/messaging-internal": { + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/node": { "name": "@bitwarden/node", "version": "0.0.0", @@ -4591,6 +4600,14 @@ "resolved": "libs/logging", "link": true }, + "node_modules/@bitwarden/messaging": { + "resolved": "libs/messaging", + "link": true + }, + "node_modules/@bitwarden/messaging-internal": { + "resolved": "libs/messaging-internal", + "link": true + }, "node_modules/@bitwarden/node": { "resolved": "libs/node", "link": true diff --git a/tsconfig.base.json b/tsconfig.base.json index c462ab97d37..478fce4bfd8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,8 @@ "@bitwarden/key-management": ["./libs/key-management/src"], "@bitwarden/key-management-ui": ["./libs/key-management-ui/src"], "@bitwarden/logging": ["libs/logging/src"], + "@bitwarden/messaging": ["libs/messaging/src/index.ts"], + "@bitwarden/messaging-internal": ["libs/messaging-internal/src/index.ts"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], From 9839087b00a3de91e8b4a87cce4d9e74a201d95b Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:30:22 -0500 Subject: [PATCH 41/54] Only return ciphers when they exist. (#15716) conditionals within the template are checking for an empty array rather than an empty ciphers property. --- .../vault-list-items-container.component.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 5fc1c43210c..5a08ed3002b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -146,14 +146,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit { ciphers: PopupCipherViewLike[]; }[] >(() => { + const ciphers = this.ciphers(); + // Not grouping by type, return a single group with all ciphers - if (!this.groupByType()) { - return [{ ciphers: this.ciphers() }]; + if (!this.groupByType() && ciphers.length > 0) { + return [{ ciphers }]; } const groups: Record<string, PopupCipherViewLike[]> = {}; - this.ciphers().forEach((cipher) => { + ciphers.forEach((cipher) => { let groupKey = "all"; switch (CipherViewLikeUtils.getType(cipher)) { case CipherType.Card: From 6aa59d5ba795a5ddd513a913ce4ea93129c65946 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:46:02 -0400 Subject: [PATCH 42/54] [BRE-831] Fixing PR target permissions (#15729) --- .github/workflows/build-browser-target.yml | 3 ++- .github/workflows/build-desktop-target.yml | 3 ++- .github/workflows/build-web-target.yml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-browser-target.yml b/.github/workflows/build-browser-target.yml index ef3beef4b8b..e89a41c1009 100644 --- a/.github/workflows/build-browser-target.yml +++ b/.github/workflows/build-browser-target.yml @@ -38,6 +38,7 @@ jobs: uses: ./.github/workflows/build-browser.yml secrets: inherit permissions: - contents: read + contents: write + pull-requests: write id-token: write diff --git a/.github/workflows/build-desktop-target.yml b/.github/workflows/build-desktop-target.yml index 31ac819a3e6..96a0e6880f8 100644 --- a/.github/workflows/build-desktop-target.yml +++ b/.github/workflows/build-desktop-target.yml @@ -38,6 +38,7 @@ jobs: uses: ./.github/workflows/build-desktop.yml secrets: inherit permissions: - contents: read + contents: write + pull-requests: write id-token: write diff --git a/.github/workflows/build-web-target.yml b/.github/workflows/build-web-target.yml index b1055885400..2f9e201ac60 100644 --- a/.github/workflows/build-web-target.yml +++ b/.github/workflows/build-web-target.yml @@ -37,7 +37,8 @@ jobs: uses: ./.github/workflows/build-web.yml secrets: inherit permissions: - contents: read + contents: write + pull-requests: write id-token: write security-events: write From 8aeeb92958bb6bd9396444ba08ccfdd52b730390 Mon Sep 17 00:00:00 2001 From: Oscar Hinton <Hinton@users.noreply.github.com> Date: Tue, 22 Jul 2025 18:48:00 +0200 Subject: [PATCH 43/54] [PM-24030] Migrate abstract services in libs/common strict TS (#15727) Migrates the abstract classes in libs/common to be strict ts compatible. Primarily by adding abstract to every field and converting it to a function syntax instead of lambda. --- libs/common/src/abstractions/api.service.ts | 446 ++++++++++-------- .../event/event-collection.service.ts | 10 +- .../event/event-upload.service.ts | 4 +- .../org-domain-api.service.abstraction.ts | 22 +- .../org-domain.service.abstraction.ts | 16 +- .../organization-api.service.abstraction.ts | 94 ++-- .../organization.service.abstraction.ts | 19 +- .../abstractions/provider.service.ts | 10 +- .../provider-api.service.abstraction.ts | 24 +- .../src/auth/abstractions/account.service.ts | 14 +- .../abstractions/anonymous-hub.service.ts | 6 +- .../src/auth/abstractions/avatar.service.ts | 4 +- .../devices-api.service.abstraction.ts | 24 +- .../src/auth/abstractions/token.service.ts | 60 ++- ...er-verification-api.service.abstraction.ts | 10 +- .../user-verification.service.abstraction.ts | 22 +- .../webauthn-login-api.service.abstraction.ts | 6 +- ...authn-login-prf-key.service.abstraction.ts | 6 +- .../webauthn-login.service.abstraction.ts | 10 +- ...account-billing-api.service.abstraction.ts | 11 +- .../billing-account-profile-state.service.ts | 2 - .../billing-api.service.abstraction.ts | 67 ++- .../organization-billing.service.ts | 20 +- .../device-trust.service.abstraction.ts | 32 +- .../vault-timeout-settings.service.ts | 18 +- .../abstractions/vault-timeout.service.ts | 8 +- ...fido2-authenticator.service.abstraction.ts | 12 +- .../fido2/fido2-client.service.abstraction.ts | 12 +- ...ido2-user-interface.service.abstraction.ts | 22 +- .../platform/abstractions/state.service.ts | 30 +- .../password-strength.service.abstraction.ts | 8 +- .../services/send-api.service.abstraction.ts | 37 +- .../send/services/send.service.abstraction.ts | 28 +- .../src/vault/abstractions/cipher.service.ts | 2 - .../file-upload/cipher-file-upload.service.ts | 6 +- .../folder/folder-api.service.abstraction.ts | 13 +- .../folder/folder.service.abstraction.ts | 32 +- .../src/vault/abstractions/search.service.ts | 22 +- .../vault-settings/vault-settings.service.ts | 20 +- 39 files changed, 595 insertions(+), 614 deletions(-) diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 4969e87f1c6..015a742c1ac 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { @@ -128,7 +126,7 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher * of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service. */ export abstract class ApiService { - send: ( + abstract send( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, @@ -136,196 +134,225 @@ export abstract class ApiService { hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, - ) => Promise<any>; + ): Promise<any>; - postIdentityToken: ( + abstract postIdentityToken( request: | PasswordTokenRequest | SsoTokenRequest | UserApiTokenRequest | WebAuthnLoginTokenRequest, - ) => Promise< + ): Promise< IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse >; - refreshIdentityToken: () => Promise<any>; + abstract refreshIdentityToken(): Promise<any>; - getProfile: () => Promise<ProfileResponse>; - getUserSubscription: () => Promise<SubscriptionResponse>; - getTaxInfo: () => Promise<TaxInfoResponse>; - putProfile: (request: UpdateProfileRequest) => Promise<ProfileResponse>; - putAvatar: (request: UpdateAvatarRequest) => Promise<ProfileResponse>; - putTaxInfo: (request: TaxInfoUpdateRequest) => Promise<any>; - postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>; - postEmailToken: (request: EmailTokenRequest) => Promise<any>; - postEmail: (request: EmailRequest) => Promise<any>; - postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>; - postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>; - getAccountRevisionDate: () => Promise<number>; - postPasswordHint: (request: PasswordHintRequest) => Promise<any>; - postPremium: (data: FormData) => Promise<PaymentResponse>; - postReinstatePremium: () => Promise<any>; - postAccountStorage: (request: StorageRequest) => Promise<PaymentResponse>; - postAccountPayment: (request: PaymentRequest) => Promise<void>; - postAccountLicense: (data: FormData) => Promise<any>; - postAccountKeys: (request: KeysRequest) => Promise<any>; - postAccountVerifyEmail: () => Promise<any>; - postAccountVerifyEmailToken: (request: VerifyEmailRequest) => Promise<any>; - postAccountRecoverDelete: (request: DeleteRecoverRequest) => Promise<any>; - postAccountRecoverDeleteToken: (request: VerifyDeleteRecoverRequest) => Promise<any>; - postAccountKdf: (request: KdfRequest) => Promise<any>; - postUserApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>; - postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>; - postConvertToKeyConnector: () => Promise<void>; + abstract getProfile(): Promise<ProfileResponse>; + abstract getUserSubscription(): Promise<SubscriptionResponse>; + abstract getTaxInfo(): Promise<TaxInfoResponse>; + abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>; + abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>; + abstract putTaxInfo(request: TaxInfoUpdateRequest): Promise<any>; + abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>; + abstract postEmailToken(request: EmailTokenRequest): Promise<any>; + abstract postEmail(request: EmailRequest): Promise<any>; + abstract postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any>; + abstract postSecurityStamp(request: SecretVerificationRequest): Promise<any>; + abstract getAccountRevisionDate(): Promise<number>; + abstract postPasswordHint(request: PasswordHintRequest): Promise<any>; + abstract postPremium(data: FormData): Promise<PaymentResponse>; + abstract postReinstatePremium(): Promise<any>; + abstract postAccountStorage(request: StorageRequest): Promise<PaymentResponse>; + abstract postAccountPayment(request: PaymentRequest): Promise<void>; + abstract postAccountLicense(data: FormData): Promise<any>; + abstract postAccountKeys(request: KeysRequest): Promise<any>; + abstract postAccountVerifyEmail(): Promise<any>; + abstract postAccountVerifyEmailToken(request: VerifyEmailRequest): Promise<any>; + abstract postAccountRecoverDelete(request: DeleteRecoverRequest): Promise<any>; + abstract postAccountRecoverDeleteToken(request: VerifyDeleteRecoverRequest): Promise<any>; + abstract postAccountKdf(request: KdfRequest): Promise<any>; + abstract postUserApiKey(id: string, request: SecretVerificationRequest): Promise<ApiKeyResponse>; + abstract postUserRotateApiKey( + id: string, + request: SecretVerificationRequest, + ): Promise<ApiKeyResponse>; + abstract postConvertToKeyConnector(): Promise<void>; //passwordless - getAuthRequest: (id: string) => Promise<AuthRequestResponse>; - putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>; - getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>; - getLastAuthRequest: () => Promise<AuthRequestResponse>; + abstract getAuthRequest(id: string): Promise<AuthRequestResponse>; + abstract putAuthRequest( + id: string, + request: PasswordlessAuthRequest, + ): Promise<AuthRequestResponse>; + abstract getAuthRequests(): Promise<ListResponse<AuthRequestResponse>>; + abstract getLastAuthRequest(): Promise<AuthRequestResponse>; - getUserBillingHistory: () => Promise<BillingHistoryResponse>; - getUserBillingPayment: () => Promise<BillingPaymentResponse>; + abstract getUserBillingHistory(): Promise<BillingHistoryResponse>; + abstract getUserBillingPayment(): Promise<BillingPaymentResponse>; - getCipher: (id: string) => Promise<CipherResponse>; - getFullCipherDetails: (id: string) => Promise<CipherResponse>; - getCipherAdmin: (id: string) => Promise<CipherResponse>; - getAttachmentData: ( + abstract getCipher(id: string): Promise<CipherResponse>; + abstract getFullCipherDetails(id: string): Promise<CipherResponse>; + abstract getCipherAdmin(id: string): Promise<CipherResponse>; + abstract getAttachmentData( cipherId: string, attachmentId: string, emergencyAccessId?: string, - ) => Promise<AttachmentResponse>; - getAttachmentDataAdmin: (cipherId: string, attachmentId: string) => Promise<AttachmentResponse>; - getCiphersOrganization: (organizationId: string) => Promise<ListResponse<CipherResponse>>; - postCipher: (request: CipherRequest) => Promise<CipherResponse>; - postCipherCreate: (request: CipherCreateRequest) => Promise<CipherResponse>; - postCipherAdmin: (request: CipherCreateRequest) => Promise<CipherResponse>; - putCipher: (id: string, request: CipherRequest) => Promise<CipherResponse>; - putPartialCipher: (id: string, request: CipherPartialRequest) => Promise<CipherResponse>; - putCipherAdmin: (id: string, request: CipherRequest) => Promise<CipherResponse>; - deleteCipher: (id: string) => Promise<any>; - deleteCipherAdmin: (id: string) => Promise<any>; - deleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise<any>; - deleteManyCiphersAdmin: (request: CipherBulkDeleteRequest) => Promise<any>; - putMoveCiphers: (request: CipherBulkMoveRequest) => Promise<any>; - putShareCipher: (id: string, request: CipherShareRequest) => Promise<CipherResponse>; - putShareCiphers: (request: CipherBulkShareRequest) => Promise<ListResponse<CipherResponse>>; - putCipherCollections: ( + ): Promise<AttachmentResponse>; + abstract getAttachmentDataAdmin( + cipherId: string, + attachmentId: string, + ): Promise<AttachmentResponse>; + abstract getCiphersOrganization(organizationId: string): Promise<ListResponse<CipherResponse>>; + abstract postCipher(request: CipherRequest): Promise<CipherResponse>; + abstract postCipherCreate(request: CipherCreateRequest): Promise<CipherResponse>; + abstract postCipherAdmin(request: CipherCreateRequest): Promise<CipherResponse>; + abstract putCipher(id: string, request: CipherRequest): Promise<CipherResponse>; + abstract putPartialCipher(id: string, request: CipherPartialRequest): Promise<CipherResponse>; + abstract putCipherAdmin(id: string, request: CipherRequest): Promise<CipherResponse>; + abstract deleteCipher(id: string): Promise<any>; + abstract deleteCipherAdmin(id: string): Promise<any>; + abstract deleteManyCiphers(request: CipherBulkDeleteRequest): Promise<any>; + abstract deleteManyCiphersAdmin(request: CipherBulkDeleteRequest): Promise<any>; + abstract putMoveCiphers(request: CipherBulkMoveRequest): Promise<any>; + abstract putShareCipher(id: string, request: CipherShareRequest): Promise<CipherResponse>; + abstract putShareCiphers(request: CipherBulkShareRequest): Promise<ListResponse<CipherResponse>>; + abstract putCipherCollections( id: string, request: CipherCollectionsRequest, - ) => Promise<OptionalCipherResponse>; - putCipherCollectionsAdmin: (id: string, request: CipherCollectionsRequest) => Promise<any>; - postPurgeCiphers: (request: SecretVerificationRequest, organizationId?: string) => Promise<any>; - putDeleteCipher: (id: string) => Promise<any>; - putDeleteCipherAdmin: (id: string) => Promise<any>; - putDeleteManyCiphers: (request: CipherBulkDeleteRequest) => Promise<any>; - putDeleteManyCiphersAdmin: (request: CipherBulkDeleteRequest) => Promise<any>; - putRestoreCipher: (id: string) => Promise<CipherResponse>; - putRestoreCipherAdmin: (id: string) => Promise<CipherResponse>; - putRestoreManyCiphers: ( + ): Promise<OptionalCipherResponse>; + abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any>; + abstract postPurgeCiphers( + request: SecretVerificationRequest, + organizationId?: string, + ): Promise<any>; + abstract putDeleteCipher(id: string): Promise<any>; + abstract putDeleteCipherAdmin(id: string): Promise<any>; + abstract putDeleteManyCiphers(request: CipherBulkDeleteRequest): Promise<any>; + abstract putDeleteManyCiphersAdmin(request: CipherBulkDeleteRequest): Promise<any>; + abstract putRestoreCipher(id: string): Promise<CipherResponse>; + abstract putRestoreCipherAdmin(id: string): Promise<CipherResponse>; + abstract putRestoreManyCiphers( request: CipherBulkRestoreRequest, - ) => Promise<ListResponse<CipherResponse>>; - putRestoreManyCiphersAdmin: ( + ): Promise<ListResponse<CipherResponse>>; + abstract putRestoreManyCiphersAdmin( request: CipherBulkRestoreRequest, - ) => Promise<ListResponse<CipherResponse>>; + ): Promise<ListResponse<CipherResponse>>; - postCipherAttachment: ( + abstract postCipherAttachment( id: string, request: AttachmentRequest, - ) => Promise<AttachmentUploadDataResponse>; - deleteCipherAttachment: (id: string, attachmentId: string) => Promise<any>; - deleteCipherAttachmentAdmin: (id: string, attachmentId: string) => Promise<any>; - postShareCipherAttachment: ( + ): Promise<AttachmentUploadDataResponse>; + abstract deleteCipherAttachment(id: string, attachmentId: string): Promise<any>; + abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any>; + abstract postShareCipherAttachment( id: string, attachmentId: string, data: FormData, organizationId: string, - ) => Promise<any>; - renewAttachmentUploadUrl: ( + ): Promise<any>; + abstract renewAttachmentUploadUrl( id: string, attachmentId: string, - ) => Promise<AttachmentUploadDataResponse>; - postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise<any>; + ): Promise<AttachmentUploadDataResponse>; + abstract postAttachmentFile(id: string, attachmentId: string, data: FormData): Promise<any>; - getUserCollections: () => Promise<ListResponse<CollectionResponse>>; - getCollections: (organizationId: string) => Promise<ListResponse<CollectionResponse>>; - getCollectionUsers: (organizationId: string, id: string) => Promise<SelectionReadOnlyResponse[]>; - getCollectionAccessDetails: ( + abstract getUserCollections(): Promise<ListResponse<CollectionResponse>>; + abstract getCollections(organizationId: string): Promise<ListResponse<CollectionResponse>>; + abstract getCollectionUsers( organizationId: string, id: string, - ) => Promise<CollectionAccessDetailsResponse>; - getManyCollectionsWithAccessDetails: ( + ): Promise<SelectionReadOnlyResponse[]>; + abstract getCollectionAccessDetails( + organizationId: string, + id: string, + ): Promise<CollectionAccessDetailsResponse>; + abstract getManyCollectionsWithAccessDetails( orgId: string, - ) => Promise<ListResponse<CollectionAccessDetailsResponse>>; - postCollection: ( + ): Promise<ListResponse<CollectionAccessDetailsResponse>>; + abstract postCollection( organizationId: string, request: CollectionRequest, - ) => Promise<CollectionDetailsResponse>; - putCollection: ( + ): Promise<CollectionDetailsResponse>; + abstract putCollection( organizationId: string, id: string, request: CollectionRequest, - ) => Promise<CollectionDetailsResponse>; - deleteCollection: (organizationId: string, id: string) => Promise<any>; - deleteManyCollections: (organizationId: string, collectionIds: string[]) => Promise<any>; + ): Promise<CollectionDetailsResponse>; + abstract deleteCollection(organizationId: string, id: string): Promise<any>; + abstract deleteManyCollections(organizationId: string, collectionIds: string[]): Promise<any>; - getGroupUsers: (organizationId: string, id: string) => Promise<string[]>; - deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>; - - getSync: () => Promise<SyncResponse>; - - getSettingsDomains: () => Promise<DomainsResponse>; - putSettingsDomains: (request: UpdateDomainsRequest) => Promise<DomainsResponse>; - - getTwoFactorProviders: () => Promise<ListResponse<TwoFactorProviderResponse>>; - getTwoFactorOrganizationProviders: ( + abstract getGroupUsers(organizationId: string, id: string): Promise<string[]>; + abstract deleteGroupUser( organizationId: string, - ) => Promise<ListResponse<TwoFactorProviderResponse>>; - getTwoFactorAuthenticator: ( + id: string, + organizationUserId: string, + ): Promise<any>; + + abstract getSync(): Promise<SyncResponse>; + + abstract getSettingsDomains(): Promise<DomainsResponse>; + abstract putSettingsDomains(request: UpdateDomainsRequest): Promise<DomainsResponse>; + + abstract getTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>>; + abstract getTwoFactorOrganizationProviders( + organizationId: string, + ): Promise<ListResponse<TwoFactorProviderResponse>>; + abstract getTwoFactorAuthenticator( request: SecretVerificationRequest, - ) => Promise<TwoFactorAuthenticatorResponse>; - getTwoFactorEmail: (request: SecretVerificationRequest) => Promise<TwoFactorEmailResponse>; - getTwoFactorDuo: (request: SecretVerificationRequest) => Promise<TwoFactorDuoResponse>; - getTwoFactorOrganizationDuo: ( + ): Promise<TwoFactorAuthenticatorResponse>; + abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse>; + abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse>; + abstract getTwoFactorOrganizationDuo( organizationId: string, request: SecretVerificationRequest, - ) => Promise<TwoFactorDuoResponse>; - getTwoFactorYubiKey: (request: SecretVerificationRequest) => Promise<TwoFactorYubiKeyResponse>; - getTwoFactorWebAuthn: (request: SecretVerificationRequest) => Promise<TwoFactorWebAuthnResponse>; - getTwoFactorWebAuthnChallenge: (request: SecretVerificationRequest) => Promise<ChallengeResponse>; - getTwoFactorRecover: (request: SecretVerificationRequest) => Promise<TwoFactorRecoverResponse>; - putTwoFactorAuthenticator: ( + ): Promise<TwoFactorDuoResponse>; + abstract getTwoFactorYubiKey( + request: SecretVerificationRequest, + ): Promise<TwoFactorYubiKeyResponse>; + abstract getTwoFactorWebAuthn( + request: SecretVerificationRequest, + ): Promise<TwoFactorWebAuthnResponse>; + abstract getTwoFactorWebAuthnChallenge( + request: SecretVerificationRequest, + ): Promise<ChallengeResponse>; + abstract getTwoFactorRecover( + request: SecretVerificationRequest, + ): Promise<TwoFactorRecoverResponse>; + abstract putTwoFactorAuthenticator( request: UpdateTwoFactorAuthenticatorRequest, - ) => Promise<TwoFactorAuthenticatorResponse>; - deleteTwoFactorAuthenticator: ( + ): Promise<TwoFactorAuthenticatorResponse>; + abstract deleteTwoFactorAuthenticator( request: DisableTwoFactorAuthenticatorRequest, - ) => Promise<TwoFactorProviderResponse>; - putTwoFactorEmail: (request: UpdateTwoFactorEmailRequest) => Promise<TwoFactorEmailResponse>; - putTwoFactorDuo: (request: UpdateTwoFactorDuoRequest) => Promise<TwoFactorDuoResponse>; - putTwoFactorOrganizationDuo: ( + ): Promise<TwoFactorProviderResponse>; + abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse>; + abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse>; + abstract putTwoFactorOrganizationDuo( organizationId: string, request: UpdateTwoFactorDuoRequest, - ) => Promise<TwoFactorDuoResponse>; - putTwoFactorYubiKey: ( + ): Promise<TwoFactorDuoResponse>; + abstract putTwoFactorYubiKey( request: UpdateTwoFactorYubikeyOtpRequest, - ) => Promise<TwoFactorYubiKeyResponse>; - putTwoFactorWebAuthn: ( + ): Promise<TwoFactorYubiKeyResponse>; + abstract putTwoFactorWebAuthn( request: UpdateTwoFactorWebAuthnRequest, - ) => Promise<TwoFactorWebAuthnResponse>; - deleteTwoFactorWebAuthn: ( + ): Promise<TwoFactorWebAuthnResponse>; + abstract deleteTwoFactorWebAuthn( request: UpdateTwoFactorWebAuthnDeleteRequest, - ) => Promise<TwoFactorWebAuthnResponse>; - putTwoFactorDisable: (request: TwoFactorProviderRequest) => Promise<TwoFactorProviderResponse>; - putTwoFactorOrganizationDisable: ( + ): Promise<TwoFactorWebAuthnResponse>; + abstract putTwoFactorDisable( + request: TwoFactorProviderRequest, + ): Promise<TwoFactorProviderResponse>; + abstract putTwoFactorOrganizationDisable( organizationId: string, request: TwoFactorProviderRequest, - ) => Promise<TwoFactorProviderResponse>; - postTwoFactorEmailSetup: (request: TwoFactorEmailRequest) => Promise<any>; - postTwoFactorEmail: (request: TwoFactorEmailRequest) => Promise<any>; - getDeviceVerificationSettings: () => Promise<DeviceVerificationResponse>; - putDeviceVerificationSettings: ( + ): Promise<TwoFactorProviderResponse>; + abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any>; + abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any>; + abstract getDeviceVerificationSettings(): Promise<DeviceVerificationResponse>; + abstract putDeviceVerificationSettings( request: DeviceVerificationRequest, - ) => Promise<DeviceVerificationResponse>; + ): Promise<DeviceVerificationResponse>; - getCloudCommunicationsEnabled: () => Promise<boolean>; + abstract getCloudCommunicationsEnabled(): Promise<boolean>; abstract getOrganizationConnection<TConfig extends OrganizationConnectionConfigApis>( id: string, type: OrganizationConnectionType, @@ -340,136 +367,147 @@ export abstract class ApiService { configType: { new (response: any): TConfig }, organizationConnectionId: string, ): Promise<OrganizationConnectionResponse<TConfig>>; - deleteOrganizationConnection: (id: string) => Promise<void>; - getPlans: () => Promise<ListResponse<PlanResponse>>; + abstract deleteOrganizationConnection(id: string): Promise<void>; + abstract getPlans(): Promise<ListResponse<PlanResponse>>; - getProviderUsers: (providerId: string) => Promise<ListResponse<ProviderUserUserDetailsResponse>>; - getProviderUser: (providerId: string, id: string) => Promise<ProviderUserResponse>; - postProviderUserInvite: (providerId: string, request: ProviderUserInviteRequest) => Promise<any>; - postProviderUserReinvite: (providerId: string, id: string) => Promise<any>; - postManyProviderUserReinvite: ( + abstract getProviderUsers( + providerId: string, + ): Promise<ListResponse<ProviderUserUserDetailsResponse>>; + abstract getProviderUser(providerId: string, id: string): Promise<ProviderUserResponse>; + abstract postProviderUserInvite( + providerId: string, + request: ProviderUserInviteRequest, + ): Promise<any>; + abstract postProviderUserReinvite(providerId: string, id: string): Promise<any>; + abstract postManyProviderUserReinvite( providerId: string, request: ProviderUserBulkRequest, - ) => Promise<ListResponse<ProviderUserBulkResponse>>; - postProviderUserAccept: ( + ): Promise<ListResponse<ProviderUserBulkResponse>>; + abstract postProviderUserAccept( providerId: string, id: string, request: ProviderUserAcceptRequest, - ) => Promise<any>; - postProviderUserConfirm: ( + ): Promise<any>; + abstract postProviderUserConfirm( providerId: string, id: string, request: ProviderUserConfirmRequest, - ) => Promise<any>; - postProviderUsersPublicKey: ( + ): Promise<any>; + abstract postProviderUsersPublicKey( providerId: string, request: ProviderUserBulkRequest, - ) => Promise<ListResponse<ProviderUserBulkPublicKeyResponse>>; - postProviderUserBulkConfirm: ( + ): Promise<ListResponse<ProviderUserBulkPublicKeyResponse>>; + abstract postProviderUserBulkConfirm( providerId: string, request: ProviderUserBulkConfirmRequest, - ) => Promise<ListResponse<ProviderUserBulkResponse>>; - putProviderUser: ( + ): Promise<ListResponse<ProviderUserBulkResponse>>; + abstract putProviderUser( providerId: string, id: string, request: ProviderUserUpdateRequest, - ) => Promise<any>; - deleteProviderUser: (organizationId: string, id: string) => Promise<any>; - deleteManyProviderUsers: ( + ): Promise<any>; + abstract deleteProviderUser(organizationId: string, id: string): Promise<any>; + abstract deleteManyProviderUsers( providerId: string, request: ProviderUserBulkRequest, - ) => Promise<ListResponse<ProviderUserBulkResponse>>; - getProviderClients: ( + ): Promise<ListResponse<ProviderUserBulkResponse>>; + abstract getProviderClients( providerId: string, - ) => Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>; - postProviderAddOrganization: ( + ): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>; + abstract postProviderAddOrganization( providerId: string, request: ProviderAddOrganizationRequest, - ) => Promise<any>; - postProviderCreateOrganization: ( + ): Promise<any>; + abstract postProviderCreateOrganization( providerId: string, request: ProviderOrganizationCreateRequest, - ) => Promise<ProviderOrganizationResponse>; - deleteProviderOrganization: (providerId: string, organizationId: string) => Promise<any>; + ): Promise<ProviderOrganizationResponse>; + abstract deleteProviderOrganization(providerId: string, organizationId: string): Promise<any>; - getEvents: (start: string, end: string, token: string) => Promise<ListResponse<EventResponse>>; - getEventsCipher: ( + abstract getEvents( + start: string, + end: string, + token: string, + ): Promise<ListResponse<EventResponse>>; + abstract getEventsCipher( id: string, start: string, end: string, token: string, - ) => Promise<ListResponse<EventResponse>>; - getEventsOrganization: ( + ): Promise<ListResponse<EventResponse>>; + abstract getEventsOrganization( id: string, start: string, end: string, token: string, - ) => Promise<ListResponse<EventResponse>>; - getEventsOrganizationUser: ( + ): Promise<ListResponse<EventResponse>>; + abstract getEventsOrganizationUser( organizationId: string, id: string, start: string, end: string, token: string, - ) => Promise<ListResponse<EventResponse>>; - getEventsProvider: ( + ): Promise<ListResponse<EventResponse>>; + abstract getEventsProvider( id: string, start: string, end: string, token: string, - ) => Promise<ListResponse<EventResponse>>; - getEventsProviderUser: ( + ): Promise<ListResponse<EventResponse>>; + abstract getEventsProviderUser( providerId: string, id: string, start: string, end: string, token: string, - ) => Promise<ListResponse<EventResponse>>; + ): Promise<ListResponse<EventResponse>>; /** * Posts events for a user * @param request The array of events to upload * @param userId The optional user id the events belong to. If no user id is provided the active user id is used. */ - postEventsCollect: (request: EventRequest[], userId?: UserId) => Promise<any>; + abstract postEventsCollect(request: EventRequest[], userId?: UserId): Promise<any>; - deleteSsoUser: (organizationId: string) => Promise<void>; - getSsoUserIdentifier: () => Promise<string>; + abstract deleteSsoUser(organizationId: string): Promise<void>; + abstract getSsoUserIdentifier(): Promise<string>; - getUserPublicKey: (id: string) => Promise<UserKeyResponse>; + abstract getUserPublicKey(id: string): Promise<UserKeyResponse>; - getHibpBreach: (username: string) => Promise<BreachAccountResponse[]>; + abstract getHibpBreach(username: string): Promise<BreachAccountResponse[]>; - postBitPayInvoice: (request: BitPayInvoiceRequest) => Promise<string>; - postSetupPayment: () => Promise<string>; + abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>; + abstract postSetupPayment(): Promise<string>; - getActiveBearerToken: () => Promise<string>; - fetch: (request: Request) => Promise<Response>; - nativeFetch: (request: Request) => Promise<Response>; + abstract getActiveBearerToken(): Promise<string>; + abstract fetch(request: Request): Promise<Response>; + abstract nativeFetch(request: Request): Promise<Response>; - preValidateSso: (identifier: string) => Promise<SsoPreValidateResponse>; + abstract preValidateSso(identifier: string): Promise<SsoPreValidateResponse>; - postCreateSponsorship: ( + abstract postCreateSponsorship( sponsorshipOrgId: string, request: OrganizationSponsorshipCreateRequest, - ) => Promise<void>; - getSponsorshipSyncStatus: ( + ): Promise<void>; + abstract getSponsorshipSyncStatus( sponsoredOrgId: string, - ) => Promise<OrganizationSponsorshipSyncStatusResponse>; - deleteRemoveSponsorship: (sponsoringOrgId: string) => Promise<void>; - postPreValidateSponsorshipToken: ( + ): Promise<OrganizationSponsorshipSyncStatusResponse>; + abstract deleteRemoveSponsorship(sponsoringOrgId: string): Promise<void>; + abstract postPreValidateSponsorshipToken( sponsorshipToken: string, - ) => Promise<PreValidateSponsorshipResponse>; - postRedeemSponsorship: ( + ): Promise<PreValidateSponsorshipResponse>; + abstract postRedeemSponsorship( sponsorshipToken: string, request: OrganizationSponsorshipRedeemRequest, - ) => Promise<void>; + ): Promise<void>; - getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise<KeyConnectorUserKeyResponse>; - postUserKeyToKeyConnector: ( + abstract getMasterKeyFromKeyConnector( + keyConnectorUrl: string, + ): Promise<KeyConnectorUserKeyResponse>; + abstract postUserKeyToKeyConnector( keyConnectorUrl: string, request: KeyConnectorUserKeyRequest, - ) => Promise<void>; - getKeyConnectorAlive: (keyConnectorUrl: string) => Promise<void>; - getOrganizationExport: (organizationId: string) => Promise<OrganizationExportResponse>; + ): Promise<void>; + abstract getKeyConnectorAlive(keyConnectorUrl: string): Promise<void>; + abstract getOrganizationExport(organizationId: string): Promise<OrganizationExportResponse>; } diff --git a/libs/common/src/abstractions/event/event-collection.service.ts b/libs/common/src/abstractions/event/event-collection.service.ts index 6ca94d93a62..4f06b76c5eb 100644 --- a/libs/common/src/abstractions/event/event-collection.service.ts +++ b/libs/common/src/abstractions/event/event-collection.service.ts @@ -1,18 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EventType } from "../../enums"; import { CipherView } from "../../vault/models/view/cipher.view"; export abstract class EventCollectionService { - collectMany: ( + abstract collectMany( eventType: EventType, ciphers: CipherView[], uploadImmediately?: boolean, - ) => Promise<any>; - collect: ( + ): Promise<any>; + abstract collect( eventType: EventType, cipherId?: string, uploadImmediately?: boolean, organizationId?: string, - ) => Promise<any>; + ): Promise<any>; } diff --git a/libs/common/src/abstractions/event/event-upload.service.ts b/libs/common/src/abstractions/event/event-upload.service.ts index af2e7a77e7f..352c7cb0255 100644 --- a/libs/common/src/abstractions/event/event-upload.service.ts +++ b/libs/common/src/abstractions/event/event-upload.service.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { UserId } from "../../types/guid"; export abstract class EventUploadService { - uploadEvents: (userId?: UserId) => Promise<void>; + abstract uploadEvents(userId?: UserId): Promise<void>; } diff --git a/libs/common/src/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction.ts index 5a393ed1996..b1452c1359b 100644 --- a/libs/common/src/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ListResponse } from "../../../models/response/list.response"; import { OrganizationDomainRequest } from "../../services/organization-domain/requests/organization-domain.request"; @@ -8,19 +6,19 @@ import { OrganizationDomainResponse } from "./responses/organization-domain.resp import { VerifiedOrganizationDomainSsoDetailsResponse } from "./responses/verified-organization-domain-sso-details.response"; export abstract class OrgDomainApiServiceAbstraction { - getAllByOrgId: (orgId: string) => Promise<Array<OrganizationDomainResponse>>; - getByOrgIdAndOrgDomainId: ( + abstract getAllByOrgId(orgId: string): Promise<Array<OrganizationDomainResponse>>; + abstract getByOrgIdAndOrgDomainId( orgId: string, orgDomainId: string, - ) => Promise<OrganizationDomainResponse>; - post: ( + ): Promise<OrganizationDomainResponse>; + abstract post( orgId: string, orgDomain: OrganizationDomainRequest, - ) => Promise<OrganizationDomainResponse>; - verify: (orgId: string, orgDomainId: string) => Promise<OrganizationDomainResponse>; - delete: (orgId: string, orgDomainId: string) => Promise<any>; - getClaimedOrgDomainByEmail: (email: string) => Promise<OrganizationDomainSsoDetailsResponse>; - getVerifiedOrgDomainsByEmail: ( + ): Promise<OrganizationDomainResponse>; + abstract verify(orgId: string, orgDomainId: string): Promise<OrganizationDomainResponse>; + abstract delete(orgId: string, orgDomainId: string): Promise<any>; + abstract getClaimedOrgDomainByEmail(email: string): Promise<OrganizationDomainSsoDetailsResponse>; + abstract getVerifiedOrgDomainsByEmail( email: string, - ) => Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>>; + ): Promise<ListResponse<VerifiedOrganizationDomainSsoDetailsResponse>>; } diff --git a/libs/common/src/admin-console/abstractions/organization-domain/org-domain.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization-domain/org-domain.service.abstraction.ts index 05a0b6d722f..7f08d226d15 100644 --- a/libs/common/src/admin-console/abstractions/organization-domain/org-domain.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization-domain/org-domain.service.abstraction.ts @@ -1,22 +1,20 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { OrganizationDomainResponse } from "./responses/organization-domain.response"; export abstract class OrgDomainServiceAbstraction { - orgDomains$: Observable<OrganizationDomainResponse[]>; + abstract orgDomains$: Observable<OrganizationDomainResponse[]>; - get: (orgDomainId: string) => OrganizationDomainResponse; + abstract get(orgDomainId: string): OrganizationDomainResponse; - copyDnsTxt: (dnsTxt: string) => void; + abstract copyDnsTxt(dnsTxt: string): void; } // Note: this separate class is designed to hold methods that are not // meant to be used in components (e.g., data write methods) export abstract class OrgDomainInternalServiceAbstraction extends OrgDomainServiceAbstraction { - upsert: (orgDomains: OrganizationDomainResponse[]) => void; - replace: (orgDomains: OrganizationDomainResponse[]) => void; - clearCache: () => void; - delete: (orgDomainIds: string[]) => void; + abstract upsert(orgDomains: OrganizationDomainResponse[]): void; + abstract replace(orgDomains: OrganizationDomainResponse[]): void; + abstract clearCache(): void; + abstract delete(orgDomainIds: string[]): void; } diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 000d1655416..10626d6758f 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { OrganizationApiKeyRequest } from "../../../admin-console/models/request/organization-api-key.request"; import { OrganizationSsoRequest } from "../../../auth/models/request/organization-sso.request"; import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; @@ -34,60 +32,66 @@ import { OrganizationKeysResponse } from "../../models/response/organization-key import { OrganizationResponse } from "../../models/response/organization.response"; import { ProfileOrganizationResponse } from "../../models/response/profile-organization.response"; -export class OrganizationApiServiceAbstraction { - get: (id: string) => Promise<OrganizationResponse>; - getBilling: (id: string) => Promise<BillingResponse>; - getBillingHistory: (id: string) => Promise<BillingHistoryResponse>; - getSubscription: (id: string) => Promise<OrganizationSubscriptionResponse>; - getLicense: (id: string, installationId: string) => Promise<unknown>; - getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>; - create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>; - createWithoutPayment: ( +export abstract class OrganizationApiServiceAbstraction { + abstract get(id: string): Promise<OrganizationResponse>; + abstract getBilling(id: string): Promise<BillingResponse>; + abstract getBillingHistory(id: string): Promise<BillingHistoryResponse>; + abstract getSubscription(id: string): Promise<OrganizationSubscriptionResponse>; + abstract getLicense(id: string, installationId: string): Promise<unknown>; + abstract getAutoEnrollStatus(identifier: string): Promise<OrganizationAutoEnrollStatusResponse>; + abstract create(request: OrganizationCreateRequest): Promise<OrganizationResponse>; + abstract createWithoutPayment( request: OrganizationNoPaymentMethodCreateRequest, - ) => Promise<OrganizationResponse>; - createLicense: (data: FormData) => Promise<OrganizationResponse>; - save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>; - updatePayment: (id: string, request: PaymentRequest) => Promise<void>; - upgrade: (id: string, request: OrganizationUpgradeRequest) => Promise<PaymentResponse>; - updatePasswordManagerSeats: ( + ): Promise<OrganizationResponse>; + abstract createLicense(data: FormData): Promise<OrganizationResponse>; + abstract save(id: string, request: OrganizationUpdateRequest): Promise<OrganizationResponse>; + abstract updatePayment(id: string, request: PaymentRequest): Promise<void>; + abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise<PaymentResponse>; + abstract updatePasswordManagerSeats( id: string, request: OrganizationSubscriptionUpdateRequest, - ) => Promise<ProfileOrganizationResponse>; - updateSecretsManagerSubscription: ( + ): Promise<ProfileOrganizationResponse>; + abstract updateSecretsManagerSubscription( id: string, request: OrganizationSmSubscriptionUpdateRequest, - ) => Promise<ProfileOrganizationResponse>; - updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>; - updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>; - verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>; - reinstate: (id: string) => Promise<void>; - leave: (id: string) => Promise<void>; - delete: (id: string, request: SecretVerificationRequest) => Promise<void>; - deleteUsingToken: ( + ): Promise<ProfileOrganizationResponse>; + abstract updateSeats(id: string, request: SeatRequest): Promise<PaymentResponse>; + abstract updateStorage(id: string, request: StorageRequest): Promise<PaymentResponse>; + abstract verifyBank(id: string, request: VerifyBankRequest): Promise<void>; + abstract reinstate(id: string): Promise<void>; + abstract leave(id: string): Promise<void>; + abstract delete(id: string, request: SecretVerificationRequest): Promise<void>; + abstract deleteUsingToken( organizationId: string, request: OrganizationVerifyDeleteRecoverRequest, - ) => Promise<any>; - updateLicense: (id: string, data: FormData) => Promise<void>; - importDirectory: (organizationId: string, request: ImportDirectoryRequest) => Promise<void>; - getOrCreateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise<ApiKeyResponse>; - getApiKeyInformation: ( + ): Promise<any>; + abstract updateLicense(id: string, data: FormData): Promise<void>; + abstract importDirectory(organizationId: string, request: ImportDirectoryRequest): Promise<void>; + abstract getOrCreateApiKey( + id: string, + request: OrganizationApiKeyRequest, + ): Promise<ApiKeyResponse>; + abstract getApiKeyInformation( id: string, organizationApiKeyType?: OrganizationApiKeyType, - ) => Promise<ListResponse<OrganizationApiKeyInformationResponse>>; - rotateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise<ApiKeyResponse>; - getTaxInfo: (id: string) => Promise<TaxInfoResponse>; - updateTaxInfo: (id: string, request: ExpandedTaxInfoUpdateRequest) => Promise<void>; - getKeys: (id: string) => Promise<OrganizationKeysResponse>; - updateKeys: (id: string, request: OrganizationKeysRequest) => Promise<OrganizationKeysResponse>; - getSso: (id: string) => Promise<OrganizationSsoResponse>; - updateSso: (id: string, request: OrganizationSsoRequest) => Promise<OrganizationSsoResponse>; - selfHostedSyncLicense: (id: string) => Promise<void>; - subscribeToSecretsManager: ( + ): Promise<ListResponse<OrganizationApiKeyInformationResponse>>; + abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise<ApiKeyResponse>; + abstract getTaxInfo(id: string): Promise<TaxInfoResponse>; + abstract updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise<void>; + abstract getKeys(id: string): Promise<OrganizationKeysResponse>; + abstract updateKeys( + id: string, + request: OrganizationKeysRequest, + ): Promise<OrganizationKeysResponse>; + abstract getSso(id: string): Promise<OrganizationSsoResponse>; + abstract updateSso(id: string, request: OrganizationSsoRequest): Promise<OrganizationSsoResponse>; + abstract selfHostedSyncLicense(id: string): Promise<void>; + abstract subscribeToSecretsManager( id: string, request: SecretsManagerSubscribeRequest, - ) => Promise<ProfileOrganizationResponse>; - updateCollectionManagement: ( + ): Promise<ProfileOrganizationResponse>; + abstract updateCollectionManagement( id: string, request: OrganizationCollectionManagementUpdateRequest, - ) => Promise<OrganizationResponse>; + ): Promise<OrganizationResponse>; } diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 05c214ece13..770cfd0011d 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { map, Observable } from "rxjs"; import { UserId } from "../../../types/guid"; @@ -68,20 +66,20 @@ export abstract class OrganizationService { * Publishes state for all organizations under the specified user. * @returns An observable list of organizations */ - organizations$: (userId: UserId) => Observable<Organization[]>; + abstract organizations$(userId: UserId): Observable<Organization[]>; // @todo Clean these up. Continuing to expand them is not recommended. // @see https://bitwarden.atlassian.net/browse/AC-2252 - memberOrganizations$: (userId: UserId) => Observable<Organization[]>; + abstract memberOrganizations$(userId: UserId): Observable<Organization[]>; /** * Emits true if the user can create or manage a Free Bitwarden Families sponsorship. */ - canManageSponsorships$: (userId: UserId) => Observable<boolean>; + abstract canManageSponsorships$(userId: UserId): Observable<boolean>; /** * Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available. */ - familySponsorshipAvailable$: (userId: UserId) => Observable<boolean>; - hasOrganizations: (userId: UserId) => Observable<boolean>; + abstract familySponsorshipAvailable$(userId: UserId): Observable<boolean>; + abstract hasOrganizations(userId: UserId): Observable<boolean>; } /** @@ -96,7 +94,7 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio * @param organization The organization state being saved. * @param userId The userId to replace state for. */ - upsert: (OrganizationData: OrganizationData, userId: UserId) => Promise<void>; + abstract upsert(OrganizationData: OrganizationData, userId: UserId): Promise<void>; /** * Replaces state for the entire registered organization list for the specified user. @@ -107,5 +105,8 @@ export abstract class InternalOrganizationServiceAbstraction extends Organizatio * user. * @param userId The userId to replace state for. */ - replace: (organizations: { [id: string]: OrganizationData }, userId: UserId) => Promise<void>; + abstract replace( + organizations: { [id: string]: OrganizationData }, + userId: UserId, + ): Promise<void>; } diff --git a/libs/common/src/admin-console/abstractions/provider.service.ts b/libs/common/src/admin-console/abstractions/provider.service.ts index 0cd21174ea1..340156020ff 100644 --- a/libs/common/src/admin-console/abstractions/provider.service.ts +++ b/libs/common/src/admin-console/abstractions/provider.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; @@ -7,8 +5,8 @@ import { ProviderData } from "../models/data/provider.data"; import { Provider } from "../models/domain/provider"; export abstract class ProviderService { - get$: (id: string) => Observable<Provider>; - get: (id: string) => Promise<Provider>; - getAll: () => Promise<Provider[]>; - save: (providers: { [id: string]: ProviderData }, userId?: UserId) => Promise<any>; + abstract get$(id: string): Observable<Provider>; + abstract get(id: string): Promise<Provider>; + abstract getAll(): Promise<Provider[]>; + abstract save(providers: { [id: string]: ProviderData }, userId?: UserId): Promise<any>; } diff --git a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts index ffe79f0ad3b..f998fdc8ab7 100644 --- a/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/provider/provider-api.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response"; import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request"; @@ -7,21 +5,23 @@ import { ProviderUpdateRequest } from "../../models/request/provider/provider-up import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request"; import { ProviderResponse } from "../../models/response/provider/provider.response"; -export class ProviderApiServiceAbstraction { - postProviderSetup: (id: string, request: ProviderSetupRequest) => Promise<ProviderResponse>; - getProvider: (id: string) => Promise<ProviderResponse>; - putProvider: (id: string, request: ProviderUpdateRequest) => Promise<ProviderResponse>; - providerRecoverDeleteToken: ( +export abstract class ProviderApiServiceAbstraction { + abstract postProviderSetup(id: string, request: ProviderSetupRequest): Promise<ProviderResponse>; + abstract getProvider(id: string): Promise<ProviderResponse>; + abstract putProvider(id: string, request: ProviderUpdateRequest): Promise<ProviderResponse>; + abstract providerRecoverDeleteToken( organizationId: string, request: ProviderVerifyRecoverDeleteRequest, - ) => Promise<any>; - deleteProvider: (id: string) => Promise<void>; - getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>; - addOrganizationToProvider: ( + ): Promise<any>; + abstract deleteProvider(id: string): Promise<void>; + abstract getProviderAddableOrganizations( + providerId: string, + ): Promise<AddableOrganizationResponse[]>; + abstract addOrganizationToProvider( providerId: string, request: { key: string; organizationId: string; }, - ) => Promise<void>; + ): Promise<void>; } diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 1686eefda06..a3dabeecf8a 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; @@ -35,20 +33,20 @@ export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { } export abstract class AccountService { - accounts$: Observable<Record<UserId, AccountInfo>>; + abstract accounts$: Observable<Record<UserId, AccountInfo>>; - activeAccount$: Observable<Account | null>; + abstract activeAccount$: Observable<Account | null>; /** * Observable of the last activity time for each account. */ - accountActivity$: Observable<Record<UserId, Date>>; + abstract accountActivity$: Observable<Record<UserId, Date>>; /** Observable of the new device login verification property for the account. */ - accountVerifyNewDeviceLogin$: Observable<boolean>; + abstract accountVerifyNewDeviceLogin$: Observable<boolean>; /** Account list in order of descending recency */ - sortedUserIds$: Observable<UserId[]>; + abstract sortedUserIds$: Observable<UserId[]>; /** Next account that is not the current active account */ - nextUpAccount$: Observable<Account>; + abstract nextUpAccount$: Observable<Account>; /** * Updates the `accounts$` observable with the new account data. * diff --git a/libs/common/src/auth/abstractions/anonymous-hub.service.ts b/libs/common/src/auth/abstractions/anonymous-hub.service.ts index 8e705d67bfe..624a3a04d53 100644 --- a/libs/common/src/auth/abstractions/anonymous-hub.service.ts +++ b/libs/common/src/auth/abstractions/anonymous-hub.service.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export abstract class AnonymousHubService { - createHubConnection: (token: string) => Promise<void>; - stopHubConnection: () => Promise<void>; + abstract createHubConnection(token: string): Promise<void>; + abstract stopHubConnection(): Promise<void>; } diff --git a/libs/common/src/auth/abstractions/avatar.service.ts b/libs/common/src/auth/abstractions/avatar.service.ts index 89729aa3712..bd2c382e610 100644 --- a/libs/common/src/auth/abstractions/avatar.service.ts +++ b/libs/common/src/auth/abstractions/avatar.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; @@ -9,7 +7,7 @@ export abstract class AvatarService { * An observable monitoring the active user's avatar color. * The observable updates when the avatar color changes. */ - avatarColor$: Observable<string | null>; + abstract avatarColor$: Observable<string | null>; /** * Sets the avatar color of the active user * diff --git a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts index cf6cdaefd85..54971a443b7 100644 --- a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts @@ -1,47 +1,45 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ListResponse } from "../../models/response/list.response"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; export abstract class DevicesApiServiceAbstraction { - getKnownDevice: (email: string, deviceIdentifier: string) => Promise<boolean>; + abstract getKnownDevice(email: string, deviceIdentifier: string): Promise<boolean>; - getDeviceByIdentifier: (deviceIdentifier: string) => Promise<DeviceResponse>; + abstract getDeviceByIdentifier(deviceIdentifier: string): Promise<DeviceResponse>; - getDevices: () => Promise<ListResponse<DeviceResponse>>; + abstract getDevices(): Promise<ListResponse<DeviceResponse>>; - updateTrustedDeviceKeys: ( + abstract updateTrustedDeviceKeys( deviceIdentifier: string, devicePublicKeyEncryptedUserKey: string, userKeyEncryptedDevicePublicKey: string, deviceKeyEncryptedDevicePrivateKey: string, - ) => Promise<DeviceResponse>; + ): Promise<DeviceResponse>; - updateTrust: ( + abstract updateTrust( updateDevicesTrustRequestModel: UpdateDevicesTrustRequest, deviceIdentifier: string, - ) => Promise<void>; + ): Promise<void>; - getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>; + abstract getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse>; /** * Notifies the server that the device has a device key, but didn't receive any associated decryption keys. * Note: For debugging purposes only. * @param deviceIdentifier - current device identifier */ - postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>; + abstract postDeviceTrustLoss(deviceIdentifier: string): Promise<void>; /** * Deactivates a device * @param deviceId - The device ID */ - deactivateDevice: (deviceId: string) => Promise<void>; + abstract deactivateDevice(deviceId: string): Promise<void>; /** * Removes trust from a list of devices * @param deviceIds - The device IDs to be untrusted */ - untrustDevices: (deviceIds: string[]) => Promise<void>; + abstract untrustDevices(deviceIds: string[]): Promise<void>; } diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 0c8db6fdcd1..2139f32fca2 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { VaultTimeout, VaultTimeoutAction } from "../../key-management/vault-timeout"; @@ -27,20 +25,20 @@ export abstract class TokenService { * * @returns A promise that resolves with the SetTokensResult containing the tokens that were set. */ - setTokens: ( + abstract setTokens( accessToken: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: VaultTimeout, refreshToken?: string, clientIdClientSecret?: [string, string], - ) => Promise<SetTokensResult>; + ): Promise<SetTokensResult>; /** * Clears the access token, refresh token, API Key Client ID, and API Key Client Secret out of memory, disk, and secure storage if supported. * @param userId The optional user id to clear the tokens for; if not provided, the active user id is used. * @returns A promise that resolves when the tokens have been cleared. */ - clearTokens: (userId?: UserId) => Promise<void>; + abstract clearTokens(userId?: UserId): Promise<void>; /** * Sets the access token in memory or disk based on the given vaultTimeoutAction and vaultTimeout @@ -51,11 +49,11 @@ export abstract class TokenService { * @param vaultTimeout The timeout for the vault. * @returns A promise that resolves with the access token that has been set. */ - setAccessToken: ( + abstract setAccessToken( accessToken: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: VaultTimeout, - ) => Promise<string>; + ): Promise<string>; // TODO: revisit having this public clear method approach once the state service is fully deprecated. /** @@ -67,21 +65,21 @@ export abstract class TokenService { * pass in the vaultTimeoutAction and vaultTimeout. * This avoids a circular dependency between the StateService, TokenService, and VaultTimeoutSettingsService. */ - clearAccessToken: (userId?: UserId) => Promise<void>; + abstract clearAccessToken(userId?: UserId): Promise<void>; /** * Gets the access token * @param userId - The optional user id to get the access token for; if not provided, the active user is used. * @returns A promise that resolves with the access token or null. */ - getAccessToken: (userId?: UserId) => Promise<string | null>; + abstract getAccessToken(userId?: UserId): Promise<string | null>; /** * Gets the refresh token. * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. * @returns A promise that resolves with the refresh token or null. */ - getRefreshToken: (userId?: UserId) => Promise<string | null>; + abstract getRefreshToken(userId?: UserId): Promise<string | null>; /** * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -90,18 +88,18 @@ export abstract class TokenService { * @param vaultTimeout The timeout for the vault. * @returns A promise that resolves with the API Key Client ID that has been set. */ - setClientId: ( + abstract setClientId( clientId: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: VaultTimeout, userId?: UserId, - ) => Promise<string>; + ): Promise<string>; /** * Gets the API Key Client ID for the active user. * @returns A promise that resolves with the API Key Client ID or undefined */ - getClientId: (userId?: UserId) => Promise<string | undefined>; + abstract getClientId(userId?: UserId): Promise<string | undefined>; /** * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -110,18 +108,18 @@ export abstract class TokenService { * @param vaultTimeout The timeout for the vault. * @returns A promise that resolves with the client secret that has been set. */ - setClientSecret: ( + abstract setClientSecret( clientSecret: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: VaultTimeout, userId?: UserId, - ) => Promise<string>; + ): Promise<string>; /** * Gets the API Key Client Secret for the active user. * @returns A promise that resolves with the API Key Client Secret or undefined */ - getClientSecret: (userId?: UserId) => Promise<string | undefined>; + abstract getClientSecret(userId?: UserId): Promise<string | undefined>; /** * Sets the two factor token for the given email in global state. @@ -131,21 +129,21 @@ export abstract class TokenService { * @param twoFactorToken The two factor token to set. * @returns A promise that resolves when the two factor token has been set. */ - setTwoFactorToken: (email: string, twoFactorToken: string) => Promise<void>; + abstract setTwoFactorToken(email: string, twoFactorToken: string): Promise<void>; /** * Gets the two factor token for the given email. * @param email The email to get the two factor token for. * @returns A promise that resolves with the two factor token for the given email or null if it isn't found. */ - getTwoFactorToken: (email: string) => Promise<string | null>; + abstract getTwoFactorToken(email: string): Promise<string | null>; /** * Clears the two factor token for the given email out of global state. * @param email The email to clear the two factor token for. * @returns A promise that resolves when the two factor token has been cleared. */ - clearTwoFactorToken: (email: string) => Promise<void>; + abstract clearTwoFactorToken(email: string): Promise<void>; /** * Decodes the access token. @@ -153,13 +151,13 @@ export abstract class TokenService { * If null, the currently active user's token is used. * @returns A promise that resolves with the decoded access token. */ - decodeAccessToken: (tokenOrUserId?: string | UserId) => Promise<DecodedAccessToken>; + abstract decodeAccessToken(tokenOrUserId?: string | UserId): Promise<DecodedAccessToken>; /** * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration * @returns A promise that resolves with the expiration date for the access token. */ - getTokenExpirationDate: () => Promise<Date | null>; + abstract getTokenExpirationDate(): Promise<Date | null>; /** * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. @@ -170,58 +168,58 @@ export abstract class TokenService { * based on the actual expiration. * @returns {Promise<number>} Promise resolving to the adjusted seconds remaining. */ - tokenSecondsRemaining: (offsetSeconds?: number) => Promise<number>; + abstract tokenSecondsRemaining(offsetSeconds?: number): Promise<number>; /** * Checks if the access token needs to be refreshed. * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. */ - tokenNeedsRefresh: (minutes?: number) => Promise<boolean>; + abstract tokenNeedsRefresh(minutes?: number): Promise<boolean>; /** * Gets the user id for the active user from the access token. * @returns A promise that resolves with the user id for the active user. * @deprecated Use AccountService.activeAccount$ instead. */ - getUserId: () => Promise<UserId>; + abstract getUserId(): Promise<UserId>; /** * Gets the email for the active user from the access token. * @returns A promise that resolves with the email for the active user. * @deprecated Use AccountService.activeAccount$ instead. */ - getEmail: () => Promise<string>; + abstract getEmail(): Promise<string>; /** * Gets the email verified status for the active user from the access token. * @returns A promise that resolves with the email verified status for the active user. */ - getEmailVerified: () => Promise<boolean>; + abstract getEmailVerified(): Promise<boolean>; /** * Gets the name for the active user from the access token. * @returns A promise that resolves with the name for the active user. * @deprecated Use AccountService.activeAccount$ instead. */ - getName: () => Promise<string>; + abstract getName(): Promise<string>; /** * Gets the issuer for the active user from the access token. * @returns A promise that resolves with the issuer for the active user. */ - getIssuer: () => Promise<string>; + abstract getIssuer(): Promise<string>; /** * Gets whether or not the user authenticated via an external mechanism. * @param userId The optional user id to check for external authN status; if not provided, the active user is used. * @returns A promise that resolves with a boolean representing the user's external authN status. */ - getIsExternal: (userId: UserId) => Promise<boolean>; + abstract getIsExternal(userId: UserId): Promise<boolean>; /** Gets the active or passed in user's security stamp */ - getSecurityStamp: (userId?: UserId) => Promise<string | null>; + abstract getSecurityStamp(userId?: UserId): Promise<string | null>; /** Sets the security stamp for the active or passed in user */ - setSecurityStamp: (securityStamp: string, userId?: UserId) => Promise<void>; + abstract setSecurityStamp(securityStamp: string, userId?: UserId): Promise<void>; } diff --git a/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts b/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts index 42abc794061..275df417df2 100644 --- a/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts @@ -1,13 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { SecretVerificationRequest } from "../../models/request/secret-verification.request"; import { VerifyOTPRequest } from "../../models/request/verify-otp.request"; import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response"; export abstract class UserVerificationApiServiceAbstraction { - postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise<void>; - postAccountRequestOTP: () => Promise<void>; - postAccountVerifyPassword: ( + abstract postAccountVerifyOTP(request: VerifyOTPRequest): Promise<void>; + abstract postAccountRequestOTP(): Promise<void>; + abstract postAccountVerifyPassword( request: SecretVerificationRequest, - ) => Promise<MasterPasswordPolicyResponse>; + ): Promise<MasterPasswordPolicyResponse>; } diff --git a/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts index 2d39854f8d9..d9749d9467c 100644 --- a/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { UserId } from "../../../types/guid"; import { SecretVerificationRequest } from "../../models/request/secret-verification.request"; import { UserVerificationOptions } from "../../types/user-verification-options"; @@ -16,9 +14,9 @@ export abstract class UserVerificationService { * @param verificationType Type of verification to restrict the options to * @returns Available verification options for the user */ - getAvailableVerificationOptions: ( + abstract getAvailableVerificationOptions( verificationType: keyof UserVerificationOptions, - ) => Promise<UserVerificationOptions>; + ): Promise<UserVerificationOptions>; /** * Create a new request model to be used for server-side verification * @param verification User-supplied verification data (Master Password or OTP) @@ -26,11 +24,11 @@ export abstract class UserVerificationService { * @param alreadyHashed Whether the master password is already hashed * @throws Error if the verification data is invalid */ - buildRequest: <T extends SecretVerificationRequest>( + abstract buildRequest<T extends SecretVerificationRequest>( verification: Verification, requestClass?: new () => T, alreadyHashed?: boolean, - ) => Promise<T>; + ): Promise<T>; /** * Verifies the user using the provided verification data. * PIN or biometrics are verified client-side. @@ -39,11 +37,11 @@ export abstract class UserVerificationService { * @param verification User-supplied verification data (OTP, MP, PIN, or biometrics) * @throws Error if the verification data is invalid or the verification fails */ - verifyUser: (verification: Verification) => Promise<boolean>; + abstract verifyUser(verification: Verification): Promise<boolean>; /** * Request a one-time password (OTP) to be sent to the user's email */ - requestOTP: () => Promise<void>; + abstract requestOTP(): Promise<void>; /** * Check if user has master password or can only use passwordless technologies to log in * Note: This only checks the server, not the local state @@ -51,13 +49,13 @@ export abstract class UserVerificationService { * @returns True if the user has a master password * @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead */ - hasMasterPassword: (userId?: string) => Promise<boolean>; + abstract hasMasterPassword(userId?: string): Promise<boolean>; /** * Check if the user has a master password and has used it during their current session * @param userId The user id to check. If not provided, the current user id used * @returns True if the user has a master password and has used it in the current session */ - hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise<boolean>; + abstract hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean>; /** * Verifies the user using the provided master password. * Attempts to verify client-side first, then server-side if necessary. @@ -68,9 +66,9 @@ export abstract class UserVerificationService { * @throws Error if the master password is invalid * @returns An object containing the master key, and master password policy options if verified on server. */ - verifyUserByMasterPassword: ( + abstract verifyUserByMasterPassword( verification: MasterPasswordVerification, userId: UserId, email: string, - ) => Promise<MasterPasswordVerificationResponse>; + ): Promise<MasterPasswordVerificationResponse>; } diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts index ca87710d22f..1e0fc124755 100644 --- a/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response"; -export class WebAuthnLoginApiServiceAbstraction { - getCredentialAssertionOptions: () => Promise<CredentialAssertionOptionsResponse>; +export abstract class WebAuthnLoginApiServiceAbstraction { + abstract getCredentialAssertionOptions(): Promise<CredentialAssertionOptionsResponse>; } diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts index 5de89313ecc..d47b7ccbcef 100644 --- a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { PrfKey } from "../../../types/key"; /** @@ -9,11 +7,11 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction { /** * Get the salt used to generate the PRF-output used when logging in with WebAuthn. */ - getLoginWithPrfSalt: () => Promise<ArrayBuffer>; + abstract getLoginWithPrfSalt(): Promise<ArrayBuffer>; /** * Create a symmetric key from the PRF-output by stretching it. * This should be used as `ExternalKey` with `RotateableKeySet`. */ - createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise<PrfKey>; + abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey>; } diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts index 8e6ffae27a8..c482b1a214e 100644 --- a/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AuthResult } from "../../models/domain/auth-result"; import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view"; @@ -14,7 +12,7 @@ export abstract class WebAuthnLoginServiceAbstraction { * (whether FIDO2 user verification is required, the relying party id, timeout duration for the process to complete, etc.) * for the authenticator. */ - getCredentialAssertionOptions: () => Promise<WebAuthnLoginCredentialAssertionOptionsView>; + abstract getCredentialAssertionOptions(): Promise<WebAuthnLoginCredentialAssertionOptionsView>; /** * Asserts the credential. This involves user interaction with the authenticator @@ -27,9 +25,9 @@ export abstract class WebAuthnLoginServiceAbstraction { * @returns {WebAuthnLoginCredentialAssertionView} The assertion obtained from the authenticator. * If the assertion is not successfully obtained, it returns undefined. */ - assertCredential: ( + abstract assertCredential( credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView, - ) => Promise<WebAuthnLoginCredentialAssertionView | undefined>; + ): Promise<WebAuthnLoginCredentialAssertionView | undefined>; /** * Logs the user in using the assertion obtained from the authenticator. @@ -39,5 +37,5 @@ export abstract class WebAuthnLoginServiceAbstraction { * @param {WebAuthnLoginCredentialAssertionView} assertion - The assertion obtained from the authenticator * that needs to be validated for login. */ - logIn: (assertion: WebAuthnLoginCredentialAssertionView) => Promise<AuthResult>; + abstract logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise<AuthResult>; } diff --git a/libs/common/src/billing/abstractions/account/account-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/account/account-billing-api.service.abstraction.ts index e0e8b7377c5..0f28e728ea2 100644 --- a/libs/common/src/billing/abstractions/account/account-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/account/account-billing-api.service.abstraction.ts @@ -1,11 +1,12 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BillingInvoiceResponse, BillingTransactionResponse, } from "../../models/response/billing.response"; -export class AccountBillingApiServiceAbstraction { - getBillingInvoices: (status?: string, startAfter?: string) => Promise<BillingInvoiceResponse[]>; - getBillingTransactions: (startAfter?: string) => Promise<BillingTransactionResponse[]>; +export abstract class AccountBillingApiServiceAbstraction { + abstract getBillingInvoices( + status?: string, + startAfter?: string, + ): Promise<BillingInvoiceResponse[]>; + abstract getBillingTransactions(startAfter?: string): Promise<BillingTransactionResponse[]>; } diff --git a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts index a4253226880..de9642f9194 100644 --- a/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts +++ b/libs/common/src/billing/abstractions/account/billing-account-profile-state.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../../types/guid"; diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index 21089933a59..2f3fe9125db 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -1,6 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; @@ -20,78 +17,78 @@ import { PaymentMethodResponse } from "../models/response/payment-method.respons import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { - cancelOrganizationSubscription: ( + abstract cancelOrganizationSubscription( organizationId: string, request: SubscriptionCancellationRequest, - ) => Promise<void>; + ): Promise<void>; - cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise<void>; + abstract cancelPremiumUserSubscription(request: SubscriptionCancellationRequest): Promise<void>; - createProviderClientOrganization: ( + abstract createProviderClientOrganization( providerId: string, request: CreateClientOrganizationRequest, - ) => Promise<void>; + ): Promise<void>; - createSetupIntent: (paymentMethodType: PaymentMethodType) => Promise<string>; + abstract createSetupIntent(paymentMethodType: PaymentMethodType): Promise<string>; - getOrganizationBillingMetadata: ( + abstract getOrganizationBillingMetadata( organizationId: string, - ) => Promise<OrganizationBillingMetadataResponse>; + ): Promise<OrganizationBillingMetadataResponse>; - getOrganizationPaymentMethod: (organizationId: string) => Promise<PaymentMethodResponse>; + abstract getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse>; - getPlans: () => Promise<ListResponse<PlanResponse>>; + abstract getPlans(): Promise<ListResponse<PlanResponse>>; - getProviderClientInvoiceReport: (providerId: string, invoiceId: string) => Promise<string>; + abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise<string>; - getProviderClientOrganizations: ( + abstract getProviderClientOrganizations( providerId: string, - ) => Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>; + ): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>>; - getProviderInvoices: (providerId: string) => Promise<InvoicesResponse>; + abstract getProviderInvoices(providerId: string): Promise<InvoicesResponse>; - getProviderSubscription: (providerId: string) => Promise<ProviderSubscriptionResponse>; + abstract getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse>; - getProviderTaxInformation: (providerId: string) => Promise<TaxInfoResponse>; + abstract getProviderTaxInformation(providerId: string): Promise<TaxInfoResponse>; - updateOrganizationPaymentMethod: ( + abstract updateOrganizationPaymentMethod( organizationId: string, request: UpdatePaymentMethodRequest, - ) => Promise<void>; + ): Promise<void>; - updateOrganizationTaxInformation: ( + abstract updateOrganizationTaxInformation( organizationId: string, request: ExpandedTaxInfoUpdateRequest, - ) => Promise<void>; + ): Promise<void>; - updateProviderClientOrganization: ( + abstract updateProviderClientOrganization( providerId: string, organizationId: string, request: UpdateClientOrganizationRequest, - ) => Promise<any>; + ): Promise<any>; - updateProviderPaymentMethod: ( + abstract updateProviderPaymentMethod( providerId: string, request: UpdatePaymentMethodRequest, - ) => Promise<void>; + ): Promise<void>; - updateProviderTaxInformation: ( + abstract updateProviderTaxInformation( providerId: string, request: ExpandedTaxInfoUpdateRequest, - ) => Promise<void>; + ): Promise<void>; - verifyOrganizationBankAccount: ( + abstract verifyOrganizationBankAccount( organizationId: string, request: VerifyBankAccountRequest, - ) => Promise<void>; + ): Promise<void>; - verifyProviderBankAccount: ( + abstract verifyProviderBankAccount( providerId: string, request: VerifyBankAccountRequest, - ) => Promise<void>; + ): Promise<void>; - restartSubscription: ( + abstract restartSubscription( organizationId: string, request: OrganizationCreateRequest, - ) => Promise<void>; + ): Promise<void>; } diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 58c537c99cc..113b55465a7 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -49,20 +47,22 @@ export type SubscriptionInformation = { }; export abstract class OrganizationBillingServiceAbstraction { - getPaymentSource: (organizationId: string) => Promise<PaymentSourceResponse>; + abstract getPaymentSource(organizationId: string): Promise<PaymentSourceResponse>; - purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>; - - purchaseSubscriptionNoPaymentMethod: ( + abstract purchaseSubscription( subscription: SubscriptionInformation, - ) => Promise<OrganizationResponse>; + ): Promise<OrganizationResponse>; - startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>; + abstract purchaseSubscriptionNoPaymentMethod( + subscription: SubscriptionInformation, + ): Promise<OrganizationResponse>; - restartSubscription: ( + abstract startFree(subscription: SubscriptionInformation): Promise<OrganizationResponse>; + + abstract restartSubscription( organizationId: string, subscription: SubscriptionInformation, - ) => Promise<void>; + ): Promise<void>; /** * Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria. diff --git a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts index d688c7f366b..2bc99e5e5c2 100644 --- a/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts +++ b/libs/common/src/key-management/device-trust/abstractions/device-trust.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { OtherDeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request"; @@ -15,51 +13,51 @@ export abstract class DeviceTrustServiceAbstraction { * by Platform * @description Checks if the device trust feature is supported for the active user. */ - supportsDeviceTrust$: Observable<boolean>; + abstract supportsDeviceTrust$: Observable<boolean>; /** * Emits when a device has been trusted. This emission is specifically for the purpose of notifying * the consuming component to display a toast informing the user the device has been trusted. */ - deviceTrusted$: Observable<void>; + abstract deviceTrusted$: Observable<void>; /** * @description Checks if the device trust feature is supported for the given user. */ - supportsDeviceTrustByUserId$: (userId: UserId) => Observable<boolean>; + abstract supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean>; /** * @description Retrieves the users choice to trust the device which can only happen after decryption * Note: this value should only be used once and then reset */ - getShouldTrustDevice: (userId: UserId) => Promise<boolean | null>; - setShouldTrustDevice: (userId: UserId, value: boolean) => Promise<void>; + abstract getShouldTrustDevice(userId: UserId): Promise<boolean | null>; + abstract setShouldTrustDevice(userId: UserId, value: boolean): Promise<void>; - trustDeviceIfRequired: (userId: UserId) => Promise<void>; + abstract trustDeviceIfRequired(userId: UserId): Promise<void>; - trustDevice: (userId: UserId) => Promise<DeviceResponse>; + abstract trustDevice(userId: UserId): Promise<DeviceResponse>; /** Retrieves the device key if it exists from state or secure storage if supported for the active user. */ - getDeviceKey: (userId: UserId) => Promise<DeviceKey | null>; - decryptUserKeyWithDeviceKey: ( + abstract getDeviceKey(userId: UserId): Promise<DeviceKey | null>; + abstract decryptUserKeyWithDeviceKey( userId: UserId, encryptedDevicePrivateKey: EncString, encryptedUserKey: EncString, deviceKey: DeviceKey, - ) => Promise<UserKey | null>; - rotateDevicesTrust: ( + ): Promise<UserKey | null>; + abstract rotateDevicesTrust( userId: UserId, newUserKey: UserKey, masterPasswordHash: string, - ) => Promise<void>; + ): Promise<void>; /** * Notifies the server that the device has a device key, but didn't receive any associated decryption keys. * Note: For debugging purposes only. */ - recordDeviceTrustLoss: () => Promise<void>; - getRotatedData: ( + abstract recordDeviceTrustLoss(): Promise<void>; + abstract getRotatedData( oldUserKey: UserKey, newUserKey: UserKey, userId: UserId, - ) => Promise<OtherDeviceKeysUpdateRequest[]>; + ): Promise<OtherDeviceKeysUpdateRequest[]>; } diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts index 9ff362e4009..bcbf0029199 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../../types/guid"; @@ -13,11 +11,11 @@ export abstract class VaultTimeoutSettingsService { * @param vaultTimeoutAction The vault timeout action * @param userId The user id to set the data for. */ - setVaultTimeoutOptions: ( + abstract setVaultTimeoutOptions( userId: UserId, vaultTimeout: VaultTimeout, vaultTimeoutAction: VaultTimeoutAction, - ) => Promise<void>; + ): Promise<void>; /** * Get the available vault timeout actions for the current user @@ -25,13 +23,13 @@ export abstract class VaultTimeoutSettingsService { * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes * @param userId The user id to check. If not provided, the current user is used */ - availableVaultTimeoutActions$: (userId?: string) => Observable<VaultTimeoutAction[]>; + abstract availableVaultTimeoutActions$(userId?: string): Observable<VaultTimeoutAction[]>; /** * Evaluates the user's available vault timeout actions and returns a boolean representing * if the user can lock or not */ - canLock: (userId: string) => Promise<boolean>; + abstract canLock(userId: string): Promise<boolean>; /** * Gets the vault timeout action for the given user id. The returned value is @@ -41,7 +39,7 @@ export abstract class VaultTimeoutSettingsService { * A new action will be emitted if the current state changes or if the user's policy changes and the new policy affects the action. * @param userId - the user id to get the vault timeout action for */ - getVaultTimeoutActionByUserId$: (userId: string) => Observable<VaultTimeoutAction>; + abstract getVaultTimeoutActionByUserId$(userId: string): Observable<VaultTimeoutAction>; /** * Get the vault timeout for the given user id. The returned value is calculated based on the current state @@ -50,14 +48,14 @@ export abstract class VaultTimeoutSettingsService { * A new timeout will be emitted if the current state changes or if the user's policy changes and the new policy affects the timeout. * @param userId The user id to get the vault timeout for */ - getVaultTimeoutByUserId$: (userId: string) => Observable<VaultTimeout>; + abstract getVaultTimeoutByUserId$(userId: string): Observable<VaultTimeout>; /** * Has the user enabled unlock with Biometric. * @param userId The user id to check. If not provided, the current user is used * @returns boolean true if biometric lock is set */ - isBiometricLockSet: (userId?: string) => Promise<boolean>; + abstract isBiometricLockSet(userId?: string): Promise<boolean>; - clear: (userId: UserId) => Promise<void>; + abstract clear(userId: UserId): Promise<void>; } diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts index cb07c7d193a..1c88a5c51ea 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export abstract class VaultTimeoutService { - checkVaultTimeout: () => Promise<void>; - lock: (userId?: string) => Promise<void>; - logOut: (userId?: string) => Promise<void>; + abstract checkVaultTimeout(): Promise<void>; + abstract lock(userId?: string): Promise<void>; + abstract logOut(userId?: string): Promise<void>; } diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index fd3453198e6..c34c4b835cf 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; /** @@ -17,11 +15,11 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> { * @param abortController An AbortController that can be used to abort the operation. * @returns A promise that resolves with the new credential and an attestation signature. **/ - makeCredential: ( + abstract makeCredential( params: Fido2AuthenticatorMakeCredentialsParams, window: ParentWindowReference, abortController?: AbortController, - ) => Promise<Fido2AuthenticatorMakeCredentialResult>; + ): Promise<Fido2AuthenticatorMakeCredentialResult>; /** * Generate an assertion using an existing credential as describe in: @@ -31,11 +29,11 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> { * @param abortController An AbortController that can be used to abort the operation. * @returns A promise that resolves with the asserted credential and an assertion signature. */ - getAssertion: ( + abstract getAssertion( params: Fido2AuthenticatorGetAssertionParams, window: ParentWindowReference, abortController?: AbortController, - ) => Promise<Fido2AuthenticatorGetAssertionResult>; + ): Promise<Fido2AuthenticatorGetAssertionResult>; /** * Discover credentials for a given Relying Party @@ -43,7 +41,7 @@ export abstract class Fido2AuthenticatorService<ParentWindowReference> { * @param rpId The Relying Party's ID * @returns A promise that resolves with an array of discoverable credentials */ - silentCredentialDiscovery: (rpId: string) => Promise<Fido2CredentialView[]>; + abstract silentCredentialDiscovery(rpId: string): Promise<Fido2CredentialView[]>; } // FIXME: update to use a const object instead of a typescript enum diff --git a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts index 55d9cce8049..f1ad26673fd 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-client.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; export type UserVerification = "discouraged" | "preferred" | "required"; @@ -16,7 +14,7 @@ export type UserVerification = "discouraged" | "preferred" | "required"; * and for returning the results of the latter operations to the Web Authentication API's callers. */ export abstract class Fido2ClientService<ParentWindowReference> { - isFido2FeatureEnabled: (hostname: string, origin: string) => Promise<boolean>; + abstract isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean>; /** * Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. @@ -26,11 +24,11 @@ export abstract class Fido2ClientService<ParentWindowReference> { * @param abortController An AbortController that can be used to abort the operation. * @returns A promise that resolves with the new credential. */ - createCredential: ( + abstract createCredential( params: CreateCredentialParams, window: ParentWindowReference, abortController?: AbortController, - ) => Promise<CreateCredentialResult>; + ): Promise<CreateCredentialResult>; /** * Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. @@ -41,11 +39,11 @@ export abstract class Fido2ClientService<ParentWindowReference> { * @param abortController An AbortController that can be used to abort the operation. * @returns A promise that resolves with the asserted credential. */ - assertCredential: ( + abstract assertCredential( params: AssertCredentialParams, window: ParentWindowReference, abortController?: AbortController, - ) => Promise<AssertCredentialResult>; + ): Promise<AssertCredentialResult>; } /** diff --git a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts index 1f871f6c70f..28b199da78f 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore /** * Parameters used to ask the user to confirm the creation of a new credential. */ @@ -69,11 +67,11 @@ export abstract class Fido2UserInterfaceService<ParentWindowReference> { * @param fallbackSupported Whether or not the browser natively supports WebAuthn. * @param abortController An abort controller that can be used to cancel/close the session. */ - newSession: ( + abstract newSession( fallbackSupported: boolean, window: ParentWindowReference, abortController?: AbortController, - ) => Promise<Fido2UserInterfaceSession>; + ): Promise<Fido2UserInterfaceSession>; } export abstract class Fido2UserInterfaceSession { @@ -84,9 +82,9 @@ export abstract class Fido2UserInterfaceSession { * @param abortController An abort controller that can be used to cancel/close the session. * @returns The ID of the cipher that contains the credentials the user picked. If not cipher was picked, return cipherId = undefined to to let the authenticator throw the error. */ - pickCredential: ( + abstract pickCredential( params: PickCredentialParams, - ) => Promise<{ cipherId: string; userVerified: boolean }>; + ): Promise<{ cipherId: string; userVerified: boolean }>; /** * Ask the user to confirm the creation of a new credential. @@ -95,30 +93,30 @@ export abstract class Fido2UserInterfaceSession { * @param abortController An abort controller that can be used to cancel/close the session. * @returns The ID of the cipher where the new credential should be saved. */ - confirmNewCredential: ( + abstract confirmNewCredential( params: NewCredentialParams, - ) => Promise<{ cipherId: string; userVerified: boolean }>; + ): Promise<{ cipherId: string; userVerified: boolean }>; /** * Make sure that the vault is unlocked. * This will open a window and ask the user to login or unlock the vault if necessary. */ - ensureUnlockedVault: () => Promise<void>; + abstract ensureUnlockedVault(): Promise<void>; /** * Inform the user that the operation was cancelled because their vault contains excluded credentials. * * @param existingCipherIds The IDs of the excluded credentials. */ - informExcludedCredential: (existingCipherIds: string[]) => Promise<void>; + abstract informExcludedCredential(existingCipherIds: string[]): Promise<void>; /** * Inform the user that the operation was cancelled because their vault does not contain any useable credentials. */ - informCredentialNotFound: (abortController?: AbortController) => Promise<void>; + abstract informCredentialNotFound(abortController?: AbortController): Promise<void>; /** * Close the session, including any windows that may be open. */ - close: () => void; + abstract close(): void; } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index e4dbe76d7e4..4c1c000284e 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { BiometricKey } from "../../auth/types/biometric-key"; import { Account } from "../models/domain/account"; import { StorageOptions } from "../models/domain/storage-options"; @@ -19,47 +17,47 @@ export type InitOptions = { }; export abstract class StateService<T extends Account = Account> { - addAccount: (account: T) => Promise<void>; - clean: (options?: StorageOptions) => Promise<void>; - init: (initOptions?: InitOptions) => Promise<void>; + abstract addAccount(account: T): Promise<void>; + abstract clean(options?: StorageOptions): Promise<void>; + abstract init(initOptions?: InitOptions): Promise<void>; /** * Gets the user's auto key */ - getUserKeyAutoUnlock: (options?: StorageOptions) => Promise<string>; + abstract getUserKeyAutoUnlock(options?: StorageOptions): Promise<string>; /** * Sets the user's auto key */ - setUserKeyAutoUnlock: (value: string | null, options?: StorageOptions) => Promise<void>; + abstract setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise<void>; /** * Gets the user's biometric key */ - getUserKeyBiometric: (options?: StorageOptions) => Promise<string>; + abstract getUserKeyBiometric(options?: StorageOptions): Promise<string>; /** * Checks if the user has a biometric key available */ - hasUserKeyBiometric: (options?: StorageOptions) => Promise<boolean>; + abstract hasUserKeyBiometric(options?: StorageOptions): Promise<boolean>; /** * Sets the user's biometric key */ - setUserKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise<void>; + abstract setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise<void>; /** * @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService */ - setEnableDuckDuckGoBrowserIntegration: ( + abstract setEnableDuckDuckGoBrowserIntegration( value: boolean, options?: StorageOptions, - ) => Promise<void>; - getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>; - setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>; + ): Promise<void>; + abstract getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string>; + abstract setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise<void>; /** * @deprecated Use `TokenService.hasAccessToken$()` or `AuthService.authStatusFor$` instead. */ - getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>; + abstract getIsAuthenticated(options?: StorageOptions): Promise<boolean>; /** * @deprecated Use `AccountService.activeAccount$` instead. */ - getUserId: (options?: StorageOptions) => Promise<string>; + abstract getUserId(options?: StorageOptions): Promise<string>; } diff --git a/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts b/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts index a49a6d481b5..ccc47d487a4 100644 --- a/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts +++ b/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts @@ -1,7 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ZXCVBNResult } from "zxcvbn"; export abstract class PasswordStrengthServiceAbstraction { - getPasswordStrength: (password: string, email?: string, userInputs?: string[]) => ZXCVBNResult; + abstract getPasswordStrength( + password: string, + email?: string, + userInputs?: string[], + ): ZXCVBNResult; } diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 570f3e746a0..80c4410af11 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; @@ -12,26 +10,29 @@ import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; export abstract class SendApiService { - getSend: (id: string) => Promise<SendResponse>; - postSendAccess: ( + abstract getSend(id: string): Promise<SendResponse>; + abstract postSendAccess( id: string, request: SendAccessRequest, apiUrl?: string, - ) => Promise<SendAccessResponse>; - getSends: () => Promise<ListResponse<SendResponse>>; - postSend: (request: SendRequest) => Promise<SendResponse>; - postFileTypeSend: (request: SendRequest) => Promise<SendFileUploadDataResponse>; - postSendFile: (sendId: string, fileId: string, data: FormData) => Promise<any>; - putSend: (id: string, request: SendRequest) => Promise<SendResponse>; - putSendRemovePassword: (id: string) => Promise<SendResponse>; - deleteSend: (id: string) => Promise<any>; - getSendFileDownloadData: ( + ): Promise<SendAccessResponse>; + abstract getSends(): Promise<ListResponse<SendResponse>>; + abstract postSend(request: SendRequest): Promise<SendResponse>; + abstract postFileTypeSend(request: SendRequest): Promise<SendFileUploadDataResponse>; + abstract postSendFile(sendId: string, fileId: string, data: FormData): Promise<any>; + abstract putSend(id: string, request: SendRequest): Promise<SendResponse>; + abstract putSendRemovePassword(id: string): Promise<SendResponse>; + abstract deleteSend(id: string): Promise<any>; + abstract getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, apiUrl?: string, - ) => Promise<SendFileDownloadDataResponse>; - renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise<SendFileUploadDataResponse>; - removePassword: (id: string) => Promise<any>; - delete: (id: string) => Promise<any>; - save: (sendData: [Send, EncArrayBuffer]) => Promise<Send>; + ): Promise<SendFileDownloadDataResponse>; + abstract renewSendFileUploadUrl( + sendId: string, + fileId: string, + ): Promise<SendFileUploadDataResponse>; + abstract removePassword(id: string): Promise<any>; + abstract delete(id: string): Promise<any>; + abstract save(sendData: [Send, EncArrayBuffer]): Promise<Send>; } diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index f586e39a755..8301172477c 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -16,49 +14,49 @@ import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; export abstract class SendService implements UserKeyRotationDataProvider<SendWithIdRequest> { - sends$: Observable<Send[]>; - sendViews$: Observable<SendView[]>; + abstract sends$: Observable<Send[]>; + abstract sendViews$: Observable<SendView[]>; - encrypt: ( + abstract encrypt( model: SendView, file: File | ArrayBuffer, password: string, key?: SymmetricCryptoKey, - ) => Promise<[Send, EncArrayBuffer]>; + ): Promise<[Send, EncArrayBuffer]>; /** * Provides a send for a determined id * updates after a change occurs to the send that matches the id * @param id The id of the desired send * @returns An observable that listens to the value of the desired send */ - get$: (id: string) => Observable<Send | undefined>; + abstract get$(id: string): Observable<Send | undefined>; /** * Provides re-encrypted user sends for the key rotation process * @param newUserKey The new user key to use for re-encryption * @throws Error if the new user key is null or undefined * @returns A list of user sends that have been re-encrypted with the new user key */ - getRotatedData: ( + abstract getRotatedData( originalUserKey: UserKey, newUserKey: UserKey, userId: UserId, - ) => Promise<SendWithIdRequest[]>; + ): Promise<SendWithIdRequest[]>; /** * @deprecated Do not call this, use the sends$ observable collection */ - getAll: () => Promise<Send[]>; + abstract getAll(): Promise<Send[]>; /** * @deprecated Only use in CLI */ - getFromState: (id: string) => Promise<Send>; + abstract getFromState(id: string): Promise<Send>; /** * @deprecated Only use in CLI */ - getAllDecryptedFromState: (userId: UserId) => Promise<SendView[]>; + abstract getAllDecryptedFromState(userId: UserId): Promise<SendView[]>; } export abstract class InternalSendService extends SendService { - upsert: (send: SendData | SendData[]) => Promise<any>; - replace: (sends: { [id: string]: SendData }, userId: UserId) => Promise<void>; - delete: (id: string | string[]) => Promise<any>; + abstract upsert(send: SendData | SendData[]): Promise<any>; + abstract replace(sends: { [id: string]: SendData }, userId: UserId): Promise<void>; + abstract delete(id: string | string[]): Promise<any>; } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2f186369463..2f4fcf0ef51 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. diff --git a/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts index 812439e2ca9..13c79241e36 100644 --- a/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EncString } from "../../../key-management/crypto/models/enc-string"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -7,11 +5,11 @@ import { Cipher } from "../../models/domain/cipher"; import { CipherResponse } from "../../models/response/cipher.response"; export abstract class CipherFileUploadService { - upload: ( + abstract upload( cipher: Cipher, encFileName: EncString, encData: EncArrayBuffer, admin: boolean, dataEncKey: [SymmetricCryptoKey, EncString], - ) => Promise<CipherResponse>; + ): Promise<CipherResponse>; } diff --git a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts index 1bb4a52e929..1b89f1664ca 100644 --- a/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder-api.service.abstraction.ts @@ -1,14 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - import { UserId } from "../../../types/guid"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; import { FolderResponse } from "../../models/response/folder.response"; -export class FolderApiServiceAbstraction { - save: (folder: Folder, userId: UserId) => Promise<FolderData>; - delete: (id: string, userId: UserId) => Promise<any>; - get: (id: string) => Promise<FolderResponse>; - deleteAll: (userId: UserId) => Promise<void>; +export abstract class FolderApiServiceAbstraction { + abstract save(folder: Folder, userId: UserId): Promise<FolderData>; + abstract delete(id: string, userId: UserId): Promise<any>; + abstract get(id: string): Promise<FolderResponse>; + abstract deleteAll(userId: UserId): Promise<void>; } diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 7324fe22c8d..e56bfda32a4 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -15,27 +13,27 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request import { FolderView } from "../../models/view/folder.view"; export abstract class FolderService implements UserKeyRotationDataProvider<FolderWithIdRequest> { - folders$: (userId: UserId) => Observable<Folder[]>; - folderViews$: (userId: UserId) => Observable<FolderView[]>; + abstract folders$(userId: UserId): Observable<Folder[]>; + abstract folderViews$(userId: UserId): Observable<FolderView[]>; - clearDecryptedFolderState: (userId: UserId) => Promise<void>; - encrypt: (model: FolderView, key: SymmetricCryptoKey) => Promise<Folder>; - get: (id: string, userId: UserId) => Promise<Folder>; - getDecrypted$: (id: string, userId: UserId) => Observable<FolderView | undefined>; + abstract clearDecryptedFolderState(userId: UserId): Promise<void>; + abstract encrypt(model: FolderView, key: SymmetricCryptoKey): Promise<Folder>; + abstract get(id: string, userId: UserId): Promise<Folder>; + abstract getDecrypted$(id: string, userId: UserId): Observable<FolderView | undefined>; /** * @deprecated Use firstValueFrom(folders$) directly instead * @param userId The user id * @returns Promise of folders array */ - getAllFromState: (userId: UserId) => Promise<Folder[]>; + abstract getAllFromState(userId: UserId): Promise<Folder[]>; /** * @deprecated Only use in CLI! */ - getFromState: (id: string, userId: UserId) => Promise<Folder>; + abstract getFromState(id: string, userId: UserId): Promise<Folder>; /** * @deprecated Only use in CLI! */ - getAllDecryptedFromState: (userId: UserId) => Promise<FolderView[]>; + abstract getAllDecryptedFromState(userId: UserId): Promise<FolderView[]>; /** * Returns user folders re-encrypted with the new user key. * @param originalUserKey the original user key @@ -44,16 +42,16 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde * @throws Error if new user key is null * @returns a list of user folders that have been re-encrypted with the new user key */ - getRotatedData: ( + abstract getRotatedData( originalUserKey: UserKey, newUserKey: UserKey, userId: UserId, - ) => Promise<FolderWithIdRequest[]>; + ): Promise<FolderWithIdRequest[]>; } export abstract class InternalFolderService extends FolderService { - upsert: (folder: FolderData | FolderData[], userId: UserId) => Promise<void>; - replace: (folders: { [id: string]: FolderData }, userId: UserId) => Promise<void>; - clear: (userId: UserId) => Promise<void>; - delete: (id: string | string[], userId: UserId) => Promise<any>; + abstract upsert(folder: FolderData | FolderData[], userId: UserId): Promise<void>; + abstract replace(folders: { [id: string]: FolderData }, userId: UserId): Promise<void>; + abstract clear(userId: UserId): Promise<void>; + abstract delete(id: string | string[], userId: UserId): Promise<any>; } diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index ed8bb2c3baf..57f301261c2 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { SendView } from "../../tools/send/models/view/send.view"; @@ -8,25 +6,25 @@ import { CipherView } from "../models/view/cipher.view"; import { CipherViewLike } from "../utils/cipher-view-like-utils"; export abstract class SearchService { - indexedEntityId$: (userId: UserId) => Observable<IndexedEntityId | null>; + abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>; - clearIndex: (userId: UserId) => Promise<void>; - isSearchable: (userId: UserId, query: string) => Promise<boolean>; - indexCiphers: ( + abstract clearIndex(userId: UserId): Promise<void>; + abstract isSearchable(userId: UserId, query: string): Promise<boolean>; + abstract indexCiphers( userId: UserId, ciphersToIndex: CipherView[], indexedEntityGuid?: string, - ) => Promise<void>; - searchCiphers: <C extends CipherViewLike>( + ): Promise<void>; + abstract searchCiphers<C extends CipherViewLike>( userId: UserId, query: string, filter?: ((cipher: C) => boolean) | ((cipher: C) => boolean)[], ciphers?: C[], - ) => Promise<C[]>; - searchCiphersBasic: <C extends CipherViewLike>( + ): Promise<C[]>; + abstract searchCiphersBasic<C extends CipherViewLike>( ciphers: C[], query: string, deleted?: boolean, - ) => C[]; - searchSends: (sends: SendView[], query: string) => SendView[]; + ): C[]; + abstract searchSends(sends: SendView[], query: string): SendView[]; } diff --git a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts index ea1e73c2685..01b0011b7f7 100644 --- a/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/abstractions/vault-settings/vault-settings.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; /** * Service for managing vault settings. @@ -9,42 +7,40 @@ export abstract class VaultSettingsService { * An observable monitoring the state of the enable passkeys setting. * The observable updates when the setting changes. */ - enablePasskeys$: Observable<boolean>; + abstract enablePasskeys$: Observable<boolean>; /** * An observable monitoring the state of the show cards on the current tab. */ - showCardsCurrentTab$: Observable<boolean>; + abstract showCardsCurrentTab$: Observable<boolean>; /** * An observable monitoring the state of the show identities on the current tab. */ - showIdentitiesCurrentTab$: Observable<boolean>; - /** + abstract showIdentitiesCurrentTab$: Observable<boolean>; /** * An observable monitoring the state of the click items on the Vault view * for Autofill suggestions. */ - clickItemsToAutofillVaultView$: Observable<boolean>; - /** + abstract clickItemsToAutofillVaultView$: Observable<boolean>; /** * Saves the enable passkeys setting to disk. * @param value The new value for the passkeys setting. */ - setEnablePasskeys: (value: boolean) => Promise<void>; + abstract setEnablePasskeys(value: boolean): Promise<void>; /** * Saves the show cards on tab page setting to disk. * @param value The new value for the show cards on tab page setting. */ - setShowCardsCurrentTab: (value: boolean) => Promise<void>; + abstract setShowCardsCurrentTab(value: boolean): Promise<void>; /** * Saves the show identities on tab page setting to disk. * @param value The new value for the show identities on tab page setting. */ - setShowIdentitiesCurrentTab: (value: boolean) => Promise<void>; + abstract setShowIdentitiesCurrentTab(value: boolean): Promise<void>; /** * Saves the click items on vault View for Autofill suggestions to disk. * @param value The new value for the click items on vault View for * Autofill suggestions setting. */ - setClickItemsToAutofillVaultView: (value: boolean) => Promise<void>; + abstract setClickItemsToAutofillVaultView(value: boolean): Promise<void>; } From 78353a988249bf109b4017b71ce8ebcc2393d496 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:04:23 -0400 Subject: [PATCH 44/54] fix(rpm): [PM-527] Remove build id links on rpm build --- apps/desktop/electron-builder.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 832ab9d0bd3..800cdd848a7 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -240,7 +240,8 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "rpm": { - "artifactName": "${productName}-${version}-${arch}.${ext}" + "artifactName": "${productName}-${version}-${arch}.${ext}", + "fpm": ["--rpm-rpmbuild-define", "_build_id_links none"] }, "freebsd": { "artifactName": "${productName}-${version}-${arch}.${ext}" From d0fc9e9a2b1fbf5457736e5d80c5eb271187f638 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:19:26 -0500 Subject: [PATCH 45/54] [PM-19589] Update delete organization user event log message (#15714) * chore: update key and message with new content, refs PM-19589 * chore: update reference to new message key, refs PM-19589 * chore: update message based on product/design review, refs PM-19589 --- apps/web/src/app/core/event.service.ts | 4 ++-- apps/web/src/locales/en/messages.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 14c87181f62..36d591cc390 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -342,9 +342,9 @@ export class EventService { ); break; case EventType.OrganizationUser_Deleted: - msg = this.i18nService.t("deletedUserId", this.formatOrgUserId(ev)); + msg = this.i18nService.t("deletedUserIdEventMessage", this.formatOrgUserId(ev)); humanReadableMsg = this.i18nService.t( - "deletedUserId", + "deletedUserIdEventMessage", this.getShortId(ev.organizationUserId), ); break; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9c9ecc79721..5d7cbd7d479 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10295,8 +10295,8 @@ "organizationUserDeletedDesc": { "message": "The user was removed from the organization and all associated user data has been deleted." }, - "deletedUserId": { - "message": "Deleted user $ID$ - an owner / admin deleted the user account", + "deletedUserIdEventMessage": { + "message": "Deleted user $ID$", "placeholders": { "id": { "content": "$1", From 319528c647ad7e57cbc6f505ab390500696223c0 Mon Sep 17 00:00:00 2001 From: Miles Blackwood <mrobinson@bitwarden.com> Date: Tue, 22 Jul 2025 14:45:59 -0400 Subject: [PATCH 46/54] Only call activeAccount$ when activeAccountStatus$ is Unlocked. (#15626) --- .../background/auto-submit-login.background.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.ts b/apps/browser/src/autofill/background/auto-submit-login.background.ts index dcafe21b63c..dfdfa0f4d67 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.ts @@ -1,12 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, switchMap } from "rxjs"; +import { filter, firstValueFrom, of, switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -51,9 +51,14 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * Initializes the auto-submit login policy. If the policy is not enabled, it * will trigger a removal of any established listeners. */ + async init() { - this.accountService.activeAccount$ + this.authService.activeAccountStatus$ .pipe( + switchMap((value) => + value === AuthenticationStatus.Unlocked ? this.accountService.activeAccount$ : of(null), + ), + filter((account): account is Account => account !== null), getUserId, switchMap((userId) => this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId), From 53aaa2c285261e706bb11914b9ce5b85b589d6b0 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum <robyntmaccallum@gmail.com> Date: Tue, 22 Jul 2025 15:41:33 -0400 Subject: [PATCH 47/54] Update tsconfig and package json (#15636) --- .../package-lock.json | 16 ++++++++++++++++ .../native-messaging-test-runner/package.json | 6 +++++- .../native-messaging-test-runner/tsconfig.json | 12 ++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 37b8cf96ff3..043393df58b 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -10,7 +10,9 @@ "license": "GPL-3.0", "dependencies": { "@bitwarden/common": "file:../../../libs/common", + "@bitwarden/logging": "dist/libs/logging/src", "@bitwarden/node": "file:../../../libs/node", + "@bitwarden/storage-core": "file:../../../libs/storage-core", "module-alias": "2.2.3", "ts-node": "10.9.2", "uuid": "11.1.0", @@ -31,14 +33,28 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "../../../libs/storage-core": { + "name": "@bitwarden/storage-core", + "version": "0.0.1", + "license": "GPL-3.0" + }, + "dist/libs/logging/src": {}, "node_modules/@bitwarden/common": { "resolved": "../../../libs/common", "link": true }, + "node_modules/@bitwarden/logging": { + "resolved": "dist/libs/logging/src", + "link": true + }, "node_modules/@bitwarden/node": { "resolved": "../../../libs/node", "link": true }, + "node_modules/@bitwarden/storage-core": { + "resolved": "../../../libs/storage-core", + "link": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index ea6b1b3e7a8..56e3e4edcf8 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -16,6 +16,8 @@ "dependencies": { "@bitwarden/common": "file:../../../libs/common", "@bitwarden/node": "file:../../../libs/node", + "@bitwarden/storage-core": "file:../../../libs/storage-core", + "@bitwarden/logging": "dist/libs/logging/src", "module-alias": "2.2.3", "ts-node": "10.9.2", "uuid": "11.1.0", @@ -27,6 +29,8 @@ }, "_moduleAliases": { "@bitwarden/common": "dist/libs/common/src", - "@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service" + "@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service", + "@bitwarden/storage-core": "dist/libs/storage-core/src", + "@bitwarden/logging": "dist/libs/logging/src" } } diff --git a/apps/desktop/native-messaging-test-runner/tsconfig.json b/apps/desktop/native-messaging-test-runner/tsconfig.json index 608e5a3bf4c..dcdf992f986 100644 --- a/apps/desktop/native-messaging-test-runner/tsconfig.json +++ b/apps/desktop/native-messaging-test-runner/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../tsconfig", "compilerOptions": { + "baseUrl": "./", "outDir": "dist", "target": "es6", "module": "CommonJS", @@ -10,7 +10,15 @@ "sourceMap": false, "declaration": false, "paths": { - "@src/*": ["src/*"] + "@src/*": ["src/*"], + "@bitwarden/user-core": ["../../../libs/user-core/src/index.ts"], + "@bitwarden/storage-core": ["../../../libs/storage-core/src/index.ts"], + "@bitwarden/logging": ["../../../libs/logging/src/index.ts"], + "@bitwarden/admin-console/*": ["../../../libs/admin-console/src/*"], + "@bitwarden/auth/*": ["../../../libs/auth/src/*"], + "@bitwarden/common/*": ["../../../libs/common/src/*"], + "@bitwarden/key-management": ["../../../libs/key-management/src/"], + "@bitwarden/node/*": ["../../../libs/node/src/*"] }, "plugins": [ { From c2bbb7c0312f27bb6a5455d43f693e0ae1918495 Mon Sep 17 00:00:00 2001 From: Oscar Hinton <Hinton@users.noreply.github.com> Date: Tue, 22 Jul 2025 21:59:42 +0200 Subject: [PATCH 48/54] Migrate vault abstract services to strict ts (#15731) --- .../src/vault/abstractions/deprecated-vault-filter.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts index 9a1a31b6068..30a4c6d4739 100644 --- a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts +++ b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. From 54f0852f1a4c306fb5d087318ac429d7c3f10811 Mon Sep 17 00:00:00 2001 From: Oscar Hinton <Hinton@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:00:07 +0200 Subject: [PATCH 49/54] Migrate auth abstract services to strict ts (#15732) --- .../set-password-jit.service.abstraction.ts | 4 +-- .../auth-request.service.abstraction.ts | 36 +++++++++---------- .../abstractions/login-strategy.service.ts | 26 +++++++------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts index da6e9368007..92db88868a2 100644 --- a/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts +++ b/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey } from "@bitwarden/common/types/key"; import { KdfConfig } from "@bitwarden/key-management"; @@ -31,5 +29,5 @@ export abstract class SetPasswordJitService { * @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey * or newKeyPair could not be created. */ - setPassword: (credentials: SetPasswordCredentials) => Promise<void>; + abstract setPassword(credentials: SetPasswordCredentials): Promise<void>; } diff --git a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts index 7e480c3a69c..9eea3fe7bb0 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; @@ -10,20 +8,20 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key"; export abstract class AuthRequestServiceAbstraction { /** Emits an auth request id when an auth request has been approved. */ - authRequestPushNotification$: Observable<string>; + abstract authRequestPushNotification$: Observable<string>; /** * Emits when a login has been approved by an admin. This emission is specifically for the * purpose of notifying the consuming component to display a toast informing the user. */ - adminLoginApproved$: Observable<void>; + abstract adminLoginApproved$: Observable<void>; /** * Returns an admin auth request for the given user if it exists. * @param userId The user id. * @throws If `userId` is not provided. */ - abstract getAdminAuthRequest: (userId: UserId) => Promise<AdminAuthRequestStorable | null>; + abstract getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null>; /** * Sets an admin auth request for the given user. * Note: use {@link clearAdminAuthRequest} to clear the request. @@ -31,16 +29,16 @@ export abstract class AuthRequestServiceAbstraction { * @param userId The user id. * @throws If `authRequest` or `userId` is not provided. */ - abstract setAdminAuthRequest: ( + abstract setAdminAuthRequest( authRequest: AdminAuthRequestStorable, userId: UserId, - ) => Promise<void>; + ): Promise<void>; /** * Clears an admin auth request for the given user. * @param userId The user id. * @throws If `userId` is not provided. */ - abstract clearAdminAuthRequest: (userId: UserId) => Promise<void>; + abstract clearAdminAuthRequest(userId: UserId): Promise<void>; /** * Gets a list of standard pending auth requests for the user. * @returns An observable of an array of auth request. @@ -61,42 +59,42 @@ export abstract class AuthRequestServiceAbstraction { * approval was successful. * @throws If the auth request is missing an id or key. */ - abstract approveOrDenyAuthRequest: ( + abstract approveOrDenyAuthRequest( approve: boolean, authRequest: AuthRequestResponse, - ) => Promise<AuthRequestResponse>; + ): Promise<AuthRequestResponse>; /** * Sets the `UserKey` from an auth request. Auth request must have a `UserKey`. * @param authReqResponse The auth request. * @param authReqPrivateKey The private key corresponding to the public key sent in the auth request. * @param userId The ID of the user for whose account we will set the key. */ - abstract setUserKeyAfterDecryptingSharedUserKey: ( + abstract setUserKeyAfterDecryptingSharedUserKey( authReqResponse: AuthRequestResponse, authReqPrivateKey: ArrayBuffer, userId: UserId, - ) => Promise<void>; + ): Promise<void>; /** * Sets the `MasterKey` and `MasterKeyHash` from an auth request. Auth request must have a `MasterKey` and `MasterKeyHash`. * @param authReqResponse The auth request. * @param authReqPrivateKey The private key corresponding to the public key sent in the auth request. * @param userId The ID of the user for whose account we will set the keys. */ - abstract setKeysAfterDecryptingSharedMasterKeyAndHash: ( + abstract setKeysAfterDecryptingSharedMasterKeyAndHash( authReqResponse: AuthRequestResponse, authReqPrivateKey: ArrayBuffer, userId: UserId, - ) => Promise<void>; + ): Promise<void>; /** * Decrypts a `UserKey` from a public key encrypted `UserKey`. * @param pubKeyEncryptedUserKey The public key encrypted `UserKey`. * @param privateKey The private key corresponding to the public key used to encrypt the `UserKey`. * @returns The decrypted `UserKey`. */ - abstract decryptPubKeyEncryptedUserKey: ( + abstract decryptPubKeyEncryptedUserKey( pubKeyEncryptedUserKey: string, privateKey: ArrayBuffer, - ) => Promise<UserKey>; + ): Promise<UserKey>; /** * Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`. * @param pubKeyEncryptedMasterKey The public key encrypted `MasterKey`. @@ -104,18 +102,18 @@ export abstract class AuthRequestServiceAbstraction { * @param privateKey The private key corresponding to the public key used to encrypt the `MasterKey` and `MasterKeyHash`. * @returns The decrypted `MasterKey` and `MasterKeyHash`. */ - abstract decryptPubKeyEncryptedMasterKeyAndHash: ( + abstract decryptPubKeyEncryptedMasterKeyAndHash( pubKeyEncryptedMasterKey: string, pubKeyEncryptedMasterKeyHash: string, privateKey: ArrayBuffer, - ) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>; + ): Promise<{ masterKey: MasterKey; masterKeyHash: string }>; /** * Handles incoming auth request push notifications. * @param notification push notification. * @remark We should only be receiving approved push notifications to prevent enumeration. */ - abstract sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => void; + abstract sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void; /** * Creates a dash-delimited fingerprint for use in confirming the `AuthRequest` between the requesting and approving device. diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index b0fffae2ab4..64854393240 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; @@ -20,60 +18,60 @@ export abstract class LoginStrategyServiceAbstraction { * The current strategy being used to authenticate. * Emits null if the session has timed out. */ - currentAuthType$: Observable<AuthenticationType | null>; + abstract currentAuthType$: Observable<AuthenticationType | null>; /** * If the login strategy uses the email address of the user, this * will return it. Otherwise, it will return null. */ - getEmail: () => Promise<string | null>; + abstract getEmail(): Promise<string | null>; /** * If the user is logging in with a master password, this will return * the master password hash. Otherwise, it will return null. */ - getMasterPasswordHash: () => Promise<string | null>; + abstract getMasterPasswordHash(): Promise<string | null>; /** * If the user is logging in with SSO, this will return * the email auth token. Otherwise, it will return null. * @see {@link SsoLoginStrategyData.ssoEmail2FaSessionToken} */ - getSsoEmail2FaSessionToken: () => Promise<string | null>; + abstract getSsoEmail2FaSessionToken(): Promise<string | null>; /** * Returns the access code if the user is logging in with an * Auth Request. Otherwise, it will return null. */ - getAccessCode: () => Promise<string | null>; + abstract getAccessCode(): Promise<string | null>; /** * Returns the auth request ID if the user is logging in with an * Auth Request. Otherwise, it will return null. */ - getAuthRequestId: () => Promise<string | null>; + abstract getAuthRequestId(): Promise<string | null>; /** * Sends a token request to the server using the provided credentials. */ - logIn: ( + abstract logIn( credentials: | UserApiLoginCredentials | PasswordLoginCredentials | SsoLoginCredentials | AuthRequestLoginCredentials | WebAuthnLoginCredentials, - ) => Promise<AuthResult>; + ): Promise<AuthResult>; /** * Sends a token request to the server with the provided two factor token. * This uses data stored from {@link LoginStrategyServiceAbstraction.logIn}, so that must be called first. * Returns an error if no session data is found. */ - logInTwoFactor: (twoFactor: TokenTwoFactorRequest) => Promise<AuthResult>; + abstract logInTwoFactor(twoFactor: TokenTwoFactorRequest): Promise<AuthResult>; /** * Creates a master key from the provided master password and email. */ - makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>; + abstract makePreloginKey(masterPassword: string, email: string): Promise<MasterKey>; /** * Emits true if the authentication session has expired. */ - authenticationSessionTimeout$: Observable<boolean>; + abstract get authenticationSessionTimeout$(): Observable<boolean>; /** * Sends a token request to the server with the provided device verification OTP. */ - logInNewDeviceVerification: (deviceVerificationOtp: string) => Promise<AuthResult>; + abstract logInNewDeviceVerification(deviceVerificationOtp: string): Promise<AuthResult>; } From c37965174b4288789f06ff9be425da70c6d92e86 Mon Sep 17 00:00:00 2001 From: Oscar Hinton <Hinton@users.noreply.github.com> Date: Tue, 22 Jul 2025 22:00:24 +0200 Subject: [PATCH 50/54] Migrate platform owned abstract service to strict ts (#15734) --- .../fido2-active-request-manager.abstraction.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts index ffb78d51bd3..390a6f4e5bd 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-active-request-manager.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable, Subject } from "rxjs"; import { Fido2CredentialView } from "../../../vault/models/view/fido2-credential.view"; @@ -25,13 +23,13 @@ export interface ActiveRequest { export type RequestCollection = Readonly<{ [tabId: number]: ActiveRequest }>; export abstract class Fido2ActiveRequestManager { - getActiveRequest$: (tabId: number) => Observable<ActiveRequest | undefined>; - getActiveRequest: (tabId: number) => ActiveRequest | undefined; - newActiveRequest: ( + abstract getActiveRequest$(tabId: number): Observable<ActiveRequest | undefined>; + abstract getActiveRequest(tabId: number): ActiveRequest | undefined; + abstract newActiveRequest( tabId: number, credentials: Fido2CredentialView[], abortController: AbortController, - ) => Promise<RequestResult>; - removeActiveRequest: (tabId: number) => void; - removeAllActiveRequests: () => void; + ): Promise<RequestResult>; + abstract removeActiveRequest(tabId: number): void; + abstract removeAllActiveRequests(): void; } From 643d0c9a4c8eeafb36bd103e9ff84cfae2cb6c23 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:56:08 -0700 Subject: [PATCH 51/54] [deps] Vault: Update form-data to v4.0.4 [SECURITY] (#15712) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 12 +++++++----- package.json | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 54855d72104..0d3c151f012 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,7 +70,7 @@ "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.44.0", - "form-data": "4.0.2", + "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", "jsdom": "26.1.0", diff --git a/package-lock.json b/package-lock.json index fbbc4c25b44..85bd2a0efb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.44.0", - "form-data": "4.0.2", + "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", "jsdom": "26.1.0", @@ -206,7 +206,7 @@ "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.44.0", - "form-data": "4.0.2", + "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", "jsdom": "26.1.0", @@ -358,6 +358,7 @@ "license": "GPL-3.0" }, "libs/messaging-internal": { + "name": "@bitwarden/messaging-internal", "version": "0.0.1", "license": "GPL-3.0" }, @@ -21194,14 +21195,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { diff --git a/package.json b/package.json index 2cb60a6afd1..bcf4f326531 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ "chalk": "4.1.2", "commander": "11.1.0", "core-js": "3.44.0", - "form-data": "4.0.2", + "form-data": "4.0.4", "https-proxy-agent": "7.0.6", "inquirer": "8.2.6", "jsdom": "26.1.0", From 2f47add6f157db355603c072fbde9cd4881b4ad0 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 22 Jul 2025 19:08:09 -0500 Subject: [PATCH 52/54] [PM-23596] Redirect to `/setup-extension` (#15641) * remove current redirection from auth code * update timeouts of the web browser interaction * add guard for setup-extension page * decrease timeout to 25ms * avoid redirection for mobile users + add tests * add tests * condense variables * catch error from profile fetch --------- Co-authored-by: Shane Melton <smelton@bitwarden.com> --- .../web-registration-finish.service.spec.ts | 22 --- .../web-registration-finish.service.ts | 15 -- apps/web/src/app/core/core.module.ts | 1 - apps/web/src/app/oss-routing.module.ts | 2 + .../add-extension-later-dialog.component.html | 8 +- ...d-extension-later-dialog.component.spec.ts | 14 ++ .../add-extension-later-dialog.component.ts | 17 ++- .../setup-extension.component.spec.ts | 16 +++ .../setup-extension.component.ts | 30 +++- .../setup-extension-redirect.guard.spec.ts | 132 ++++++++++++++++++ .../guards/setup-extension-redirect.guard.ts | 109 +++++++++++++++ .../web-browser-interaction.service.spec.ts | 6 +- .../web-browser-interaction.service.ts | 17 ++- .../default-registration-finish.service.ts | 4 - .../registration-finish.component.ts | 3 +- .../registration-finish.service.ts | 5 - .../src/platform/state/state-definitions.ts | 7 + 17 files changed, 347 insertions(+), 61 deletions(-) create mode 100644 apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts create mode 100644 apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index 69a2f27a322..845df89622b 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -12,7 +12,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; @@ -30,7 +29,6 @@ describe("WebRegistrationFinishService", () => { let policyApiService: MockProxy<PolicyApiServiceAbstraction>; let logService: MockProxy<LogService>; let policyService: MockProxy<PolicyService>; - let configService: MockProxy<ConfigService>; beforeEach(() => { keyService = mock<KeyService>(); @@ -39,7 +37,6 @@ describe("WebRegistrationFinishService", () => { policyApiService = mock<PolicyApiServiceAbstraction>(); logService = mock<LogService>(); policyService = mock<PolicyService>(); - configService = mock<ConfigService>(); service = new WebRegistrationFinishService( keyService, @@ -48,7 +45,6 @@ describe("WebRegistrationFinishService", () => { policyApiService, logService, policyService, - configService, ); }); @@ -414,22 +410,4 @@ describe("WebRegistrationFinishService", () => { ); }); }); - - describe("determineLoginSuccessRoute", () => { - it("returns /setup-extension when the end user activation feature flag is enabled", async () => { - configService.getFeatureFlag.mockResolvedValue(true); - - const result = await service.determineLoginSuccessRoute(); - - expect(result).toBe("/setup-extension"); - }); - - it("returns /vault when the end user activation feature flag is disabled", async () => { - configService.getFeatureFlag.mockResolvedValue(false); - - const result = await service.determineLoginSuccessRoute(); - - expect(result).toBe("/vault"); - }); - }); }); diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts index a3774e87db8..a9eba08be8c 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -14,12 +14,10 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { KeyService } from "@bitwarden/key-management"; @@ -34,7 +32,6 @@ export class WebRegistrationFinishService private policyApiService: PolicyApiServiceAbstraction, private logService: LogService, private policyService: PolicyService, - private configService: ConfigService, ) { super(keyService, accountApiService); } @@ -79,18 +76,6 @@ export class WebRegistrationFinishService return masterPasswordPolicyOpts; } - override async determineLoginSuccessRoute(): Promise<string> { - const endUserActivationFlagEnabled = await this.configService.getFeatureFlag( - FeatureFlag.PM19315EndUserActivationMvp, - ); - - if (endUserActivationFlagEnabled) { - return "/setup-extension"; - } else { - return super.determineLoginSuccessRoute(); - } - } - // Note: the org invite token and email verification are mutually exclusive. Only one will be present. override async buildRegisterRequest( email: string, diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index d98a2ee8cf2..7fe8ef4c79f 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -264,7 +264,6 @@ const safeProviders: SafeProvider[] = [ PolicyApiServiceAbstraction, LogService, PolicyService, - ConfigService, ], }), safeProvider({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 8a2270113a9..1fb19757d60 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -83,6 +83,7 @@ import { SendComponent } from "./tools/send/send.component"; import { BrowserExtensionPromptInstallComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt-install.component"; import { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component"; import { SetupExtensionComponent } from "./vault/components/setup-extension/setup-extension.component"; +import { setupExtensionRedirectGuard } from "./vault/guards/setup-extension-redirect.guard"; import { VaultModule } from "./vault/individual-vault/vault.module"; const routes: Routes = [ @@ -628,6 +629,7 @@ const routes: Routes = [ children: [ { path: "vault", + canActivate: [setupExtensionRedirectGuard], loadChildren: () => VaultModule, }, { diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html index df1786e227e..560bd5fd464 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html @@ -18,7 +18,13 @@ > {{ "getTheExtension" | i18n }} </a> - <a bitButton buttonType="secondary" routerLink="/vault" bitDialogClose> + <a + bitButton + buttonType="secondary" + routerLink="/vault" + bitDialogClose + (click)="dismissExtensionPage()" + > {{ "skipToWebApp" | i18n }} </a> </ng-container> diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts index d34dba737dd..a5d5ec4b939 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts @@ -1,3 +1,4 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; @@ -5,20 +6,26 @@ import { RouterModule } from "@angular/router"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DIALOG_DATA } from "@bitwarden/components"; import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component"; describe("AddExtensionLaterDialogComponent", () => { let fixture: ComponentFixture<AddExtensionLaterDialogComponent>; const getDevice = jest.fn().mockReturnValue(null); + const onDismiss = jest.fn(); beforeEach(async () => { + onDismiss.mockClear(); + await TestBed.configureTestingModule({ imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])], providers: [ provideNoopAnimations(), { provide: PlatformUtilsService, useValue: { getDevice } }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogRef, useValue: { close: jest.fn() } }, + { provide: DIALOG_DATA, useValue: { onDismiss } }, ], }).compileComponents(); @@ -39,4 +46,11 @@ describe("AddExtensionLaterDialogComponent", () => { expect(skipLink.attributes.href).toBe("/vault"); }); + + it('invokes `onDismiss` when "Skip to Web App" is clicked', () => { + const skipLink = fixture.debugElement.queryAll(By.css("a[bitButton]"))[1]; + skipLink.triggerEventHandler("click", {}); + + expect(onDismiss).toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts index 3324cb8b1b0..5f4e3f586f5 100644 --- a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts @@ -4,7 +4,17 @@ import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; -import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/components"; +import { + ButtonComponent, + DIALOG_DATA, + DialogModule, + TypographyModule, +} from "@bitwarden/components"; + +export type AddExtensionLaterDialogData = { + /** Method invoked when the dialog is dismissed */ + onDismiss: () => void; +}; @Component({ selector: "vault-add-extension-later-dialog", @@ -13,6 +23,7 @@ import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/comp }) export class AddExtensionLaterDialogComponent implements OnInit { private platformUtilsService = inject(PlatformUtilsService); + private data: AddExtensionLaterDialogData = inject(DIALOG_DATA); /** Download Url for the extension based on the browser */ protected webStoreUrl: string = ""; @@ -20,4 +31,8 @@ export class AddExtensionLaterDialogComponent implements OnInit { ngOnInit(): void { this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice()); } + + async dismissExtensionPage() { + this.data.onDismiss(); + } } diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts index 752e2c8d4a6..e824cd92f37 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts @@ -3,12 +3,14 @@ import { By } from "@angular/platform-browser"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; @@ -21,11 +23,13 @@ describe("SetupExtensionComponent", () => { const getFeatureFlag = jest.fn().mockResolvedValue(false); const navigate = jest.fn().mockResolvedValue(true); const openExtension = jest.fn().mockResolvedValue(true); + const update = jest.fn().mockResolvedValue(true); const extensionInstalled$ = new BehaviorSubject<boolean | null>(null); beforeEach(async () => { navigate.mockClear(); openExtension.mockClear(); + update.mockClear(); getFeatureFlag.mockClear().mockResolvedValue(true); window.matchMedia = jest.fn().mockReturnValue(false); @@ -36,6 +40,14 @@ describe("SetupExtensionComponent", () => { { provide: ConfigService, useValue: { getFeatureFlag } }, { provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } }, { provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } }, + { + provide: AccountService, + useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) }, + }, + { + provide: StateProvider, + useValue: { getUser: () => ({ update }) }, + }, ], }).compileComponents(); @@ -120,6 +132,10 @@ describe("SetupExtensionComponent", () => { expect(openExtension).toHaveBeenCalled(); }); + + it("dismisses the extension page", () => { + expect(update).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index 9ee8e189627..14770ca5d6c 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -2,13 +2,16 @@ import { DOCUMENT, NgIf } from "@angular/common"; import { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; -import { pairwise, startWith } from "rxjs"; +import { firstValueFrom, pairwise, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; import { @@ -20,9 +23,13 @@ import { } from "@bitwarden/components"; import { VaultIcons } from "@bitwarden/vault"; +import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard"; import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; -import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component"; +import { + AddExtensionLaterDialogComponent, + AddExtensionLaterDialogData, +} from "./add-extension-later-dialog.component"; import { AddExtensionVideosComponent } from "./add-extension-videos.component"; const SetupExtensionState = { @@ -53,6 +60,8 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private platformUtilsService = inject(PlatformUtilsService); private dialogService = inject(DialogService); + private stateProvider = inject(StateProvider); + private accountService = inject(AccountService); private document = inject(DOCUMENT); protected SetupExtensionState = SetupExtensionState; @@ -96,6 +105,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { // Extension was not installed and now it is, show success state if (previousState === false && currentState) { this.dialogRef?.close(); + void this.dismissExtensionPage(); this.state = SetupExtensionState.Success; } @@ -125,17 +135,31 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { const isMobile = Utils.isMobileBrowser; if (!isFeatureEnabled || isMobile) { + await this.dismissExtensionPage(); await this.router.navigate(["/vault"]); } } /** Opens the add extension later dialog */ addItLater() { - this.dialogRef = this.dialogService.open(AddExtensionLaterDialogComponent); + this.dialogRef = this.dialogService.open<unknown, AddExtensionLaterDialogData>( + AddExtensionLaterDialogComponent, + { + data: { + onDismiss: this.dismissExtensionPage.bind(this), + }, + }, + ); } /** Opens the browser extension */ openExtension() { void this.webBrowserExtensionInteractionService.openExtension(); } + + /** Update local state to never show this page again. */ + private async dismissExtensionPage() { + const accountId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + void this.stateProvider.getUser(accountId, SETUP_EXTENSION_DISMISSED).update(() => true); + } } diff --git a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts new file mode 100644 index 00000000000..e6fc03fd844 --- /dev/null +++ b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.spec.ts @@ -0,0 +1,132 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { WebBrowserInteractionService } from "../services/web-browser-interaction.service"; + +import { setupExtensionRedirectGuard } from "./setup-extension-redirect.guard"; + +describe("setupExtensionRedirectGuard", () => { + const _state = Object.freeze({}) as RouterStateSnapshot; + const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot; + const seventeenDaysAgo = new Date(); + seventeenDaysAgo.setDate(seventeenDaysAgo.getDate() - 17); + + const account = { + id: "account-id", + } as unknown as Account; + + const activeAccount$ = new BehaviorSubject<Account | null>(account); + const extensionInstalled$ = new BehaviorSubject<boolean>(false); + const state$ = new BehaviorSubject<boolean>(false); + const createUrlTree = jest.fn(); + const getFeatureFlag = jest.fn().mockImplementation((key) => { + if (key === FeatureFlag.PM19315EndUserActivationMvp) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + const getProfileCreationDate = jest.fn().mockResolvedValue(seventeenDaysAgo); + + beforeEach(() => { + Utils.isMobileBrowser = false; + + getFeatureFlag.mockClear(); + getProfileCreationDate.mockClear(); + createUrlTree.mockClear(); + + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { createUrlTree } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: StateProvider, useValue: { getUser: () => ({ state$ }) } }, + { provide: WebBrowserInteractionService, useValue: { extensionInstalled$ } }, + { + provide: VaultProfileService, + useValue: { getProfileCreationDate }, + }, + ], + }); + }); + + function setupExtensionGuard(route?: ActivatedRouteSnapshot) { + // Run the guard within injection context so `inject` works as you'd expect + // Pass state object to make TypeScript happy + return TestBed.runInInjectionContext(async () => + setupExtensionRedirectGuard(route ?? emptyRoute, _state), + ); + } + + it("returns `true` when the profile was created more than 30 days ago", async () => { + const thirtyOneDaysAgo = new Date(); + thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31); + + getProfileCreationDate.mockResolvedValueOnce(thirtyOneDaysAgo); + + expect(await setupExtensionGuard()).toBe(true); + }); + + it("returns `true` when the profile check fails", async () => { + getProfileCreationDate.mockRejectedValueOnce(new Error("Profile check failed")); + + expect(await setupExtensionGuard()).toBe(true); + }); + + it("returns `true` when the feature flag is disabled", async () => { + getFeatureFlag.mockResolvedValueOnce(false); + + expect(await setupExtensionGuard()).toBe(true); + }); + + it("returns `true` when the user is on a mobile device", async () => { + Utils.isMobileBrowser = true; + + expect(await setupExtensionGuard()).toBe(true); + }); + + it("returns `true` when the user has dismissed the extension page", async () => { + state$.next(true); + + expect(await setupExtensionGuard()).toBe(true); + }); + + it("returns `true` when the user has the extension installed", async () => { + state$.next(false); + extensionInstalled$.next(true); + + expect(await setupExtensionGuard()).toBe(true); + }); + + it('redirects the user to "/setup-extension" when all criteria do not pass', async () => { + state$.next(false); + extensionInstalled$.next(false); + + await setupExtensionGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/setup-extension"]); + }); + + describe("missing current account", () => { + afterAll(() => { + // reset `activeAccount$` observable + activeAccount$.next(account); + }); + + it("redirects to login when account is missing", async () => { + activeAccount$.next(null); + + await setupExtensionGuard(); + + expect(createUrlTree).toHaveBeenCalledWith(["/login"]); + }); + }); +}); diff --git a/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts new file mode 100644 index 00000000000..983fd8ed0aa --- /dev/null +++ b/apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts @@ -0,0 +1,109 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom, map } from "rxjs"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + SETUP_EXTENSION_DISMISSED_DISK, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; + +import { WebBrowserInteractionService } from "../services/web-browser-interaction.service"; + +export const SETUP_EXTENSION_DISMISSED = new UserKeyDefinition<boolean>( + SETUP_EXTENSION_DISMISSED_DISK, + "setupExtensionDismissed", + { + deserializer: (dismissed) => dismissed, + clearOn: [], + }, +); + +export const setupExtensionRedirectGuard: CanActivateFn = async () => { + const router = inject(Router); + const configService = inject(ConfigService); + const accountService = inject(AccountService); + const vaultProfileService = inject(VaultProfileService); + const stateProvider = inject(StateProvider); + const webBrowserInteractionService = inject(WebBrowserInteractionService); + + const isMobile = Utils.isMobileBrowser; + + const endUserFeatureEnabled = await configService.getFeatureFlag( + FeatureFlag.PM19315EndUserActivationMvp, + ); + + // The extension page isn't applicable for mobile users, do not redirect them. + // Include before any other checks to avoid unnecessary processing. + if (!endUserFeatureEnabled || isMobile) { + return true; + } + + const currentAcct = await firstValueFrom(accountService.activeAccount$); + + if (!currentAcct) { + return router.createUrlTree(["/login"]); + } + + const hasExtensionInstalledPromise = firstValueFrom( + webBrowserInteractionService.extensionInstalled$, + ); + + const dismissedExtensionPage = await firstValueFrom( + stateProvider + .getUser(currentAcct.id, SETUP_EXTENSION_DISMISSED) + .state$.pipe(map((dismissed) => dismissed ?? false)), + ); + + const isProfileOlderThan30Days = await profileIsOlderThan30Days( + vaultProfileService, + currentAcct.id, + ).catch( + () => + // If the call for the profile fails for any reason, do not block the user + true, + ); + + if (dismissedExtensionPage || isProfileOlderThan30Days) { + return true; + } + + // Checking for the extension is a more expensive operation, do it last to avoid unnecessary delays. + const hasExtensionInstalled = await hasExtensionInstalledPromise; + + if (hasExtensionInstalled) { + return true; + } + + return router.createUrlTree(["/setup-extension"]); +}; + +/** Returns true when the user's profile is older than 30 days */ +async function profileIsOlderThan30Days( + vaultProfileService: VaultProfileService, + userId: string, +): Promise<boolean> { + const creationDate = await vaultProfileService.getProfileCreationDate(userId); + return isMoreThan30DaysAgo(creationDate); +} + +/** Returns the true when the date given is older than 30 days */ +function isMoreThan30DaysAgo(date?: string | Date): boolean { + if (!date) { + return false; + } + + const inputDate = new Date(date).getTime(); + const today = new Date().getTime(); + + const differenceInMS = today - inputDate; + const msInADay = 1000 * 60 * 60 * 24; + const differenceInDays = Math.round(differenceInMS / msInADay); + + return differenceInDays > 30; +} diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts index fef5d45e8c3..bfbfb0fb676 100644 --- a/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts +++ b/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts @@ -38,7 +38,7 @@ describe("WebBrowserInteractionService", () => { expect(installed).toBe(false); }); - tick(1500); + tick(150); })); it("returns true when the extension is installed", (done) => { @@ -58,13 +58,13 @@ describe("WebBrowserInteractionService", () => { }); // initial timeout, should emit false - tick(1500); + tick(26); expect(results[0]).toBe(false); tick(2500); // then emit `HasBwInstalled` dispatchEvent(VaultMessages.HasBwInstalled); - tick(); + tick(26); expect(results[1]).toBe(true); })); }); diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.ts index f1005ef6dc9..1f91942591b 100644 --- a/apps/web/src/app/vault/services/web-browser-interaction.service.ts +++ b/apps/web/src/app/vault/services/web-browser-interaction.service.ts @@ -21,10 +21,19 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; /** - * The amount of time in milliseconds to wait for a response from the browser extension. + * The amount of time in milliseconds to wait for a response from the browser extension. A longer duration is + * used to allow for the extension to open and then emit to the message. * NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond. */ -const MESSAGE_RESPONSE_TIMEOUT_MS = 1500; +const OPEN_RESPONSE_TIMEOUT_MS = 1500; + +/** + * Timeout for checking if the extension is installed. + * + * A shorter timeout is used to avoid waiting for too long for the extension. The listener for + * checking the installation runs in the background scripts so the response should be relatively quick. + */ +const CHECK_FOR_EXTENSION_TIMEOUT_MS = 25; @Injectable({ providedIn: "root", @@ -63,7 +72,7 @@ export class WebBrowserInteractionService { filter((event) => event.data.command === VaultMessages.PopupOpened), map(() => true), ), - timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)), + timer(OPEN_RESPONSE_TIMEOUT_MS).pipe(map(() => false)), ) .pipe(take(1)) .subscribe((didOpen) => { @@ -85,7 +94,7 @@ export class WebBrowserInteractionService { filter((event) => event.data.command === VaultMessages.HasBwInstalled), map(() => true), ), - timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)), + timer(CHECK_FOR_EXTENSION_TIMEOUT_MS).pipe(map(() => false)), ).pipe( tap({ subscribe: () => { diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts index b51f45f1b27..2bef5670ac3 100644 --- a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts @@ -28,10 +28,6 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi return null; } - determineLoginSuccessRoute(): Promise<string> { - return Promise.resolve("/vault"); - } - async finishRegistration( email: string, passwordInputResult: PasswordInputResult, diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index 1d1a2d8f892..dac62f039ee 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -204,8 +204,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { await this.loginSuccessHandlerService.run(authenticationResult.userId); - const successRoute = await this.registrationFinishService.determineLoginSuccessRoute(); - await this.router.navigate([successRoute]); + await this.router.navigate(["/vault"]); } catch (e) { // If login errors, redirect to login page per product. Don't show error this.logService.error("Error logging in after registration: ", e.message); diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts index 523a4c79c54..5f3c04e5155 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts @@ -16,11 +16,6 @@ export abstract class RegistrationFinishService { */ abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null>; - /** - * Returns the route the user is redirected to after a successful login. - */ - abstract determineLoginSuccessRoute(): Promise<string>; - /** * Finishes the registration process by creating a new user account. * diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 93c489a343e..a1c3ee35c5c 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -202,6 +202,13 @@ export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk"); export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk"); export const NUDGES_DISK = new StateDefinition("nudges", "disk", { web: "disk-local" }); +export const SETUP_EXTENSION_DISMISSED_DISK = new StateDefinition( + "setupExtensionDismissed", + "disk", + { + web: "disk-local", + }, +); export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", From e8629e5e1b276973ab9820e4ddc4a7a215bc69be Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 23 Jul 2025 14:00:07 +0100 Subject: [PATCH 53/54] Resolve the dropdown display error (#15704) --- .../src/app/billing/settings/sponsored-families.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.html b/apps/web/src/app/billing/settings/sponsored-families.component.html index 7708f63365e..7d240cb0665 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.html +++ b/apps/web/src/app/billing/settings/sponsored-families.component.html @@ -28,7 +28,7 @@ > <bit-option [disabled]="true" - [value]="" + [value]="null" [label]="'--' + ('select' | i18n) + '--'" ></bit-option> <bit-option From 6b7658191852112273d297e60c2587353a8b366b Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:51:02 -0400 Subject: [PATCH 54/54] Removing the notifications feature flag and logic (#15551) --- .../critical-applications.component.html | 1 - .../access-intelligence/critical-applications.component.ts | 6 ------ libs/common/src/enums/feature-flag.enum.ts | 6 ------ 3 files changed, 13 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html index 4e2b4e5c404..ffef3f3b0b9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html @@ -29,7 +29,6 @@ <div class="tw-flex tw-justify-between tw-mb-4"> <h2 bitTypography="h2">{{ "criticalApplications" | i18n }}</h2> <button - *ngIf="isNotificationsFeatureEnabled" bitButton buttonType="primary" type="button" diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index fcca568da6e..4f9a9930f1c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -16,7 +16,6 @@ import { ApplicationHealthReportDetailWithCriticalFlagAndCipher, ApplicationHealthReportSummary, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -63,14 +62,9 @@ export class CriticalApplicationsComponent implements OnInit { protected organizationId: string; protected applicationSummary = {} as ApplicationHealthReportSummary; noItemsIcon = Icons.Security; - isNotificationsFeatureEnabled: boolean = false; enableRequestPasswordChange = false; async ngOnInit() { - this.isNotificationsFeatureEnabled = await this.configService.getFeatureFlag( - FeatureFlag.EnableRiskInsightsNotifications, - ); - this.organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId") ?? ""; combineLatest([ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8e9dc6fd35e..114ace8bd8e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,9 +35,6 @@ export enum FeatureFlag { AllowTrialLengthZero = "pm-20322-allow-trial-length-0", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", - /* Data Insights and Reporting */ - EnableRiskInsightsNotifications = "enable-risk-insights-notifications", - /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", @@ -89,9 +86,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, - /* Data Insights and Reporting */ - [FeatureFlag.EnableRiskInsightsNotifications]: FALSE, - /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE,