mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-22180] Setup Extension Videos (#15419)
* remove placeholder image * add videos for setup extension * add support for mobile viewports * add mobile/responsiveness for setup extension page * add videos from `assets.bitwarden.com` * align with figma for borders and shadow * make text responsive for setup headings * remove period * add tests * add tests for video sequence * force font weight on `h2` * add 8px to bottom margin of video container
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<ng-template #newLoginItem>
|
||||
<video
|
||||
#video
|
||||
muted
|
||||
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
|
||||
[attr.aria-hidden]="!allVideosLoaded"
|
||||
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
|
||||
(loadeddata)="onVideoLoad()"
|
||||
src="https://assets.bitwarden.com/extension-animations/new-login-item.mp4"
|
||||
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/new-login-item-dark.mp4"
|
||||
aria-hidden
|
||||
></video>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #browserExtensionEasyAccess>
|
||||
<video
|
||||
#video
|
||||
muted
|
||||
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
|
||||
[attr.aria-hidden]="!allVideosLoaded"
|
||||
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
|
||||
(loadeddata)="onVideoLoad()"
|
||||
src="https://assets.bitwarden.com/extension-animations/browser-extension-easy-access.mp4"
|
||||
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/browser-extension-easy-access-dark.mp4"
|
||||
aria-hidden
|
||||
></video>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #onboardingAutofill>
|
||||
<video
|
||||
#video
|
||||
muted
|
||||
[ngClass]="{ 'tw-opacity-0': !allVideosLoaded }"
|
||||
[attr.aria-hidden]="!allVideosLoaded"
|
||||
class="tw-block tw-max-w-full tw-shadow-md tw-rounded-lg"
|
||||
(loadeddata)="onVideoLoad()"
|
||||
src="https://assets.bitwarden.com/extension-animations/onboarding-autofill.mp4"
|
||||
appDarkImgSrc="https://assets.bitwarden.com/extension-animations/onboarding-autofill-dark.mp4"
|
||||
aria-hidden
|
||||
></video>
|
||||
</ng-template>
|
||||
|
||||
<!--
|
||||
On desktop, videos are shown in a 1 row, 3 column grid.
|
||||
Below tablet viewports, videos are shown one at a time. The videos are absolute positioned, except the first.
|
||||
The first video is relatively positioned to force the layout and spacing of the videos.
|
||||
-->
|
||||
<div
|
||||
class="tw-mx-auto tw-w-[15rem] tw-mb-8 tw-relative md:tw-grid md:tw-gap-10 md:tw-w-auto md:tw-grid-rows-1 md:tw-grid-cols-3 md:tw-justify-center md:tw-justify-items-center"
|
||||
[attr.aria-label]="'setupExtensionContentAlt' | i18n"
|
||||
>
|
||||
<div class="tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]">
|
||||
<div
|
||||
*ngIf="!allVideosLoaded"
|
||||
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
|
||||
data-testid="video-pulse"
|
||||
></div>
|
||||
<ng-container *ngTemplateOutlet="newLoginItem"></ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
|
||||
>
|
||||
<div
|
||||
*ngIf="!allVideosLoaded"
|
||||
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
|
||||
data-testid="video-pulse"
|
||||
></div>
|
||||
<ng-container *ngTemplateOutlet="browserExtensionEasyAccess"></ng-container>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-absolute tw-left-0 tw-top-0 tw-opacity-0 md:tw-opacity-100 md:tw-relative tw-w-[15rem] tw-max-w-full tw-aspect-[0.807]"
|
||||
>
|
||||
<div
|
||||
*ngIf="!allVideosLoaded"
|
||||
class="tw-animate-pulse tw-bg-secondary-300 tw-size-full tw-absolute tw-left-0 tw-top-0 tw-rounded-lg"
|
||||
data-testid="video-pulse"
|
||||
></div>
|
||||
<ng-container *ngTemplateOutlet="onboardingAutofill"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||
|
||||
describe("AddExtensionVideosComponent", () => {
|
||||
let fixture: ComponentFixture<AddExtensionVideosComponent>;
|
||||
let component: AddExtensionVideosComponent;
|
||||
|
||||
// Mock HTMLMediaElement load to stop the video file from being loaded
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const play = jest.fn(() => Promise.resolve());
|
||||
HTMLMediaElement.prototype.play = play;
|
||||
|
||||
beforeEach(async () => {
|
||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||
play.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddExtensionVideosComponent, RouterModule.forRoot([])],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AddExtensionVideosComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("loading pulse", () => {
|
||||
it("shows loading spinner when all videos are not loaded", () => {
|
||||
const loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
|
||||
expect(loadingSpinners.length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows all pulses until all videos are loaded", () => {
|
||||
let loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
|
||||
expect(loadingSpinners.length).toBe(3);
|
||||
|
||||
// Simulate two video loaded
|
||||
component["videoElements"].get(0)?.nativeElement.dispatchEvent(new Event("loadeddata"));
|
||||
component["videoElements"].get(1)?.nativeElement.dispatchEvent(new Event("loadeddata"));
|
||||
|
||||
loadingSpinners = fixture.debugElement.queryAll(By.css("[data-testid='video-pulse']"));
|
||||
|
||||
expect(component["numberOfLoadedVideos"]).toBe(2);
|
||||
expect(loadingSpinners.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("window resizing", () => {
|
||||
beforeEach(() => {
|
||||
component["numberOfLoadedVideos"] = 3;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("shows all videos when window is resized to desktop viewport", fakeAsync(() => {
|
||||
component["variant"] = "mobile";
|
||||
Object.defineProperty(component["document"].documentElement, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick(50);
|
||||
|
||||
expect(
|
||||
Array.from(component["videoElements"]).every(
|
||||
(video) => video.nativeElement.parentElement?.style.opacity === "1",
|
||||
),
|
||||
).toBe(true);
|
||||
}));
|
||||
|
||||
it("shows only the playing video when window is resized to mobile viewport", fakeAsync(() => {
|
||||
component["variant"] = "desktop";
|
||||
// readonly property needs redefining
|
||||
Object.defineProperty(component["document"].documentElement, "clientWidth", {
|
||||
value: 500,
|
||||
});
|
||||
|
||||
const video1 = component["videoElements"].get(1);
|
||||
Object.defineProperty(video1!.nativeElement, "paused", {
|
||||
value: false,
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick(50);
|
||||
|
||||
expect(component["videoElements"].get(0)?.nativeElement.parentElement?.style.opacity).toBe(
|
||||
"0",
|
||||
);
|
||||
expect(component["videoElements"].get(1)?.nativeElement.parentElement?.style.opacity).toBe(
|
||||
"1",
|
||||
);
|
||||
expect(component["videoElements"].get(2)?.nativeElement.parentElement?.style.opacity).toBe(
|
||||
"0",
|
||||
);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("video sequence", () => {
|
||||
let firstVideo: HTMLVideoElement;
|
||||
let secondVideo: HTMLVideoElement;
|
||||
let thirdVideo: HTMLVideoElement;
|
||||
|
||||
beforeEach(() => {
|
||||
component["numberOfLoadedVideos"] = 2;
|
||||
component["onVideoLoad"]();
|
||||
|
||||
firstVideo = component["videoElements"].get(0)!.nativeElement;
|
||||
secondVideo = component["videoElements"].get(1)!.nativeElement;
|
||||
thirdVideo = component["videoElements"].get(2)!.nativeElement;
|
||||
});
|
||||
|
||||
it("starts the video sequence when all videos are loaded", fakeAsync(() => {
|
||||
tick();
|
||||
|
||||
expect(firstVideo.play).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("plays videos in sequence", fakeAsync(() => {
|
||||
tick(); // let first video play
|
||||
|
||||
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(() => {
|
||||
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);
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { CommonModule, DOCUMENT } from "@angular/common";
|
||||
import { Component, ViewChildren, QueryList, ElementRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { debounceTime, fromEvent } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
|
||||
@Component({
|
||||
selector: "vault-add-extension-videos",
|
||||
templateUrl: "./add-extension-videos.component.html",
|
||||
imports: [CommonModule, JslibModule],
|
||||
})
|
||||
export class AddExtensionVideosComponent {
|
||||
@ViewChildren("video", { read: ElementRef }) protected videoElements!: QueryList<
|
||||
ElementRef<HTMLVideoElement>
|
||||
>;
|
||||
|
||||
private document = inject(DOCUMENT);
|
||||
|
||||
/** Current viewport size */
|
||||
protected variant: "mobile" | "desktop" = "desktop";
|
||||
|
||||
/** Number of videos that have loaded and are ready to play */
|
||||
protected numberOfLoadedVideos = 0;
|
||||
|
||||
/** True when the user prefers reduced motion */
|
||||
protected prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
/** Returns true when all videos are loaded */
|
||||
get allVideosLoaded(): boolean {
|
||||
return this.numberOfLoadedVideos >= 3;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
fromEvent(window, "resize")
|
||||
.pipe(takeUntilDestroyed(), debounceTime(25))
|
||||
.subscribe(() => this.onResize());
|
||||
}
|
||||
|
||||
/** Resets the video states based on the viewport width changes */
|
||||
onResize(): void {
|
||||
const oldVariant = this.variant;
|
||||
this.variant = this.document.documentElement.clientWidth < 768 ? "mobile" : "desktop";
|
||||
|
||||
// When the viewport changes from desktop to mobile, hide all videos except the one that is playing.
|
||||
if (this.variant !== oldVariant && this.variant === "mobile") {
|
||||
this.videoElements.forEach((video) => {
|
||||
if (video.nativeElement.paused) {
|
||||
this.hideElement(video.nativeElement.parentElement!);
|
||||
} else {
|
||||
this.showElement(video.nativeElement.parentElement!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When the viewport changes from mobile to desktop, show all videos.
|
||||
if (this.variant !== oldVariant && this.variant === "desktop") {
|
||||
this.videoElements.forEach((video) => {
|
||||
this.showElement(video.nativeElement.parentElement!);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the number of loaded videos.
|
||||
* When all videos are loaded, start the first one.
|
||||
*/
|
||||
protected onVideoLoad() {
|
||||
this.numberOfLoadedVideos = this.numberOfLoadedVideos + 1;
|
||||
|
||||
if (this.allVideosLoaded) {
|
||||
void this.startVideoSequence(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursive method to start the video sequence. */
|
||||
private async startVideoSequence(i: number): Promise<void> {
|
||||
let index = i;
|
||||
const endOfVideos = index >= this.videoElements.length;
|
||||
|
||||
// When the user prefers reduced motion, don't play the videos more than once
|
||||
if (endOfVideos && this.prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the last of the videos has played, loop back to the start
|
||||
if (endOfVideos) {
|
||||
this.videoElements.forEach((video) => {
|
||||
// Reset all videos to the start
|
||||
video.nativeElement.currentTime = 0;
|
||||
});
|
||||
|
||||
// Loop back to the first video
|
||||
index = 0;
|
||||
}
|
||||
|
||||
const video = this.videoElements.toArray()[index].nativeElement;
|
||||
video.onended = () => {
|
||||
void this.startVideoSequence(index + 1);
|
||||
};
|
||||
|
||||
this.mobileTransitionIn(index);
|
||||
|
||||
// Set muted via JavaScript, browsers are respecting autoplay consistently over just the HTML attribute
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
}
|
||||
|
||||
/** For mobile viewports, fades the current video out and the next video in. */
|
||||
private mobileTransitionIn(index: number): void {
|
||||
// When the viewport is above the tablet breakpoint, all videos are shown at once.
|
||||
// No transition is needed.
|
||||
if (this.isAboveTabletBreakpoint()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentParent = this.videoElements.toArray()[index].nativeElement.parentElement!;
|
||||
const previousIndex = index === 0 ? this.videoElements.length - 1 : index - 1;
|
||||
|
||||
const previousParent = this.videoElements.toArray()[previousIndex].nativeElement.parentElement!;
|
||||
|
||||
// Fade out the previous video
|
||||
this.hideElement(previousParent, true);
|
||||
|
||||
// Fade in the current video
|
||||
this.showElement(currentParent, true);
|
||||
}
|
||||
|
||||
/** Returns true when the viewport width is 768px or above. */
|
||||
private isAboveTabletBreakpoint(): boolean {
|
||||
const width = this.document.documentElement.clientWidth;
|
||||
return width >= 768;
|
||||
}
|
||||
|
||||
/** Visually hides the given element. */
|
||||
private hideElement(element: HTMLElement, transition = false): void {
|
||||
element.style.transition = transition ? "opacity 0.5s linear" : "";
|
||||
element.style.opacity = "0";
|
||||
}
|
||||
|
||||
/** Visually shows the given element. */
|
||||
private showElement(element: HTMLElement, transition = false): void {
|
||||
element.style.transition = transition ? "opacity 0.5s linear" : "";
|
||||
element.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,13 @@
|
||||
></i>
|
||||
|
||||
<section *ngIf="state === SetupExtensionState.NeedsExtension" class="tw-text-center tw-mt-4">
|
||||
<h1 bitTypography="h2">{{ "setupExtensionPageTitle" | i18n }}</h1>
|
||||
<h2 bitTypography="body1">{{ "setupExtensionPageDescription" | i18n }}</h2>
|
||||
<div class="tw-mb-6">
|
||||
<!-- Placeholder - will be removed in following tickets -->
|
||||
<img
|
||||
class="tw-max-w-3xl"
|
||||
[alt]="'setupExtensionContentAlt' | i18n"
|
||||
src="/images/setup-extension/setup-extension-placeholder.png"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="tw-text-xl tw-font-semibold tw-text-main sm:tw-text-2xl">
|
||||
{{ "setupExtensionPageTitle" | i18n }}
|
||||
</h1>
|
||||
<h2 class="tw-text-sm tw-text-main tw-mb-6 tw-font-normal sm:tw-text-base">
|
||||
{{ "setupExtensionPageDescription" | i18n }}
|
||||
</h2>
|
||||
<vault-add-extension-videos></vault-add-extension-videos>
|
||||
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center">
|
||||
<a
|
||||
bitButton
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("SetupExtensionComponent", () => {
|
||||
navigate.mockClear();
|
||||
openExtension.mockClear();
|
||||
getFeatureFlag.mockClear().mockResolvedValue(true);
|
||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SetupExtensionComponent, RouterModule.forRoot([])],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NgIf } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnInit } from "@angular/core";
|
||||
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";
|
||||
@@ -23,6 +23,7 @@ import { VaultIcons } from "@bitwarden/vault";
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
|
||||
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
|
||||
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||
|
||||
const SetupExtensionState = {
|
||||
Loading: "loading",
|
||||
@@ -35,15 +36,24 @@ type SetupExtensionState = UnionOfValues<typeof SetupExtensionState>;
|
||||
@Component({
|
||||
selector: "vault-setup-extension",
|
||||
templateUrl: "./setup-extension.component.html",
|
||||
imports: [NgIf, JslibModule, ButtonComponent, LinkModule, IconModule, RouterModule],
|
||||
imports: [
|
||||
NgIf,
|
||||
JslibModule,
|
||||
ButtonComponent,
|
||||
LinkModule,
|
||||
IconModule,
|
||||
RouterModule,
|
||||
AddExtensionVideosComponent,
|
||||
],
|
||||
})
|
||||
export class SetupExtensionComponent implements OnInit {
|
||||
export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
private webBrowserExtensionInteractionService = inject(WebBrowserInteractionService);
|
||||
private configService = inject(ConfigService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private dialogService = inject(DialogService);
|
||||
private document = inject(DOCUMENT);
|
||||
|
||||
protected SetupExtensionState = SetupExtensionState;
|
||||
protected PartyIcon = VaultIcons.Party;
|
||||
@@ -56,8 +66,21 @@ export class SetupExtensionComponent implements OnInit {
|
||||
|
||||
/** Reference to the add it later dialog */
|
||||
protected dialogRef: DialogRef | null = null;
|
||||
private viewportContent: string | null = null;
|
||||
|
||||
async ngOnInit() {
|
||||
// It is not be uncommon for users to hit this page from smaller viewports.
|
||||
// There are global styles that set a min-width for the page which cause it to render poorly.
|
||||
// Remove them here.
|
||||
// https://github.com/bitwarden/clients/blob/main/apps/web/src/scss/base.scss#L6
|
||||
this.document.body.style.minWidth = "auto";
|
||||
|
||||
const viewportMeta = this.document.querySelector('meta[name="viewport"]');
|
||||
|
||||
// Save the current viewport content to reset it when the component is destroyed
|
||||
this.viewportContent = viewportMeta?.getAttribute("content") ?? null;
|
||||
viewportMeta?.setAttribute("content", "width=device-width, initial-scale=1.0");
|
||||
|
||||
await this.conditionallyRedirectUser();
|
||||
|
||||
this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice());
|
||||
@@ -83,6 +106,17 @@ export class SetupExtensionComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Reset the body min-width when the component is destroyed
|
||||
this.document.body.style.minWidth = "";
|
||||
|
||||
if (this.viewportContent !== null) {
|
||||
this.document
|
||||
.querySelector('meta[name="viewport"]')
|
||||
?.setAttribute("content", this.viewportContent);
|
||||
}
|
||||
}
|
||||
|
||||
/** Conditionally redirects the user to the vault upon landing on the page. */
|
||||
async conditionallyRedirectUser() {
|
||||
const isFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 770 KiB |
@@ -10746,15 +10746,15 @@
|
||||
},
|
||||
"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'."
|
||||
"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",
|
||||
"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'."
|
||||
"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",
|
||||
"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'."
|
||||
"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."
|
||||
|
||||
@@ -268,6 +268,9 @@ const devServer =
|
||||
https://www.paypalobjects.com
|
||||
https://q.stripe.com
|
||||
https://haveibeenpwned.com
|
||||
;media-src
|
||||
'self'
|
||||
https://assets.bitwarden.com
|
||||
;child-src
|
||||
'self'
|
||||
https://js.stripe.com
|
||||
|
||||
Reference in New Issue
Block a user