From b2a995462f4537f18c5ebddd78c34a489aef9028 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 6 Aug 2024 16:02:47 -0700 Subject: [PATCH] [PM-9605] Extension AnonLayout wrapper component (#10338) * setup extension component * setup extension service * update icon based on theme, adjust padding, service injection * override service * add stories * add current-account component * add ConfigService to storybook * use null checks for boolean data - otherwise false values are ignored * update translations * remove router implementation test * remove imports in main.background.ts * add showLogo to template * update icon usage * fix app-current-account storybook style issue --- ...ension-anon-layout-wrapper-data.service.ts | 23 ++ ...tension-anon-layout-wrapper.component.html | 28 ++ ...extension-anon-layout-wrapper.component.ts | 190 +++++++++++ .../extension-anon-layout-wrapper.stories.ts | 294 ++++++++++++++++++ .../extension-bitwarden-logo.icon.ts | 35 +++ .../src/popup/services/services.module.ts | 7 + .../anon-layout-wrapper.component.ts | 2 +- .../anon-layout/anon-layout.component.html | 3 +- .../anon-layout/anon-layout.component.ts | 1 + ...efault-anon-layout-wrapper-data.service.ts | 2 +- libs/components/src/styles.scss | 7 + 11 files changed, 589 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts create mode 100644 apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html create mode 100644 apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts create mode 100644 apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts create mode 100644 apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts new file mode 100644 index 00000000000..1b844d4b2c7 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts @@ -0,0 +1,23 @@ +import { Observable, Subject } from "rxjs"; + +import { + AnonLayoutWrapperDataService, + DefaultAnonLayoutWrapperDataService, +} from "@bitwarden/auth/angular"; + +import { ExtensionAnonLayoutWrapperData } from "./extension-anon-layout-wrapper.component"; + +export class ExtensionAnonLayoutWrapperDataService + extends DefaultAnonLayoutWrapperDataService + implements AnonLayoutWrapperDataService +{ + protected override anonLayoutWrapperDataSubject = new Subject(); + + override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void { + this.anonLayoutWrapperDataSubject.next(data); + } + + override anonLayoutWrapperData$(): Observable { + return this.anonLayoutWrapperDataSubject.asObservable(); + } +} diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html new file mode 100644 index 00000000000..e7082f40196 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts new file mode 100644 index 00000000000..df6e313342b --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -0,0 +1,190 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; +import { Subject, filter, firstValueFrom, switchMap, takeUntil, tap } from "rxjs"; + +import { + AnonLayoutComponent, + AnonLayoutWrapperData, + AnonLayoutWrapperDataService, +} from "@bitwarden/auth/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { Icon, IconModule } from "@bitwarden/components"; + +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"; +import { CurrentAccountComponent } from "../account-switching/current-account.component"; + +import { + ExtensionBitwardenLogoPrimary, + ExtensionBitwardenLogoWhite, +} from "./extension-bitwarden-logo.icon"; + +export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { + showAcctSwitcher?: boolean; + showBackButton?: boolean; + showLogo?: boolean; +} + +@Component({ + standalone: true, + templateUrl: "extension-anon-layout-wrapper.component.html", + imports: [ + AnonLayoutComponent, + CommonModule, + CurrentAccountComponent, + IconModule, + PopOutComponent, + PopupPageComponent, + PopupHeaderComponent, + RouterModule, + ], +}) +export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + protected showAcctSwitcher: boolean; + protected showBackButton: boolean; + protected showLogo: boolean = true; + + protected pageTitle: string; + protected pageSubtitle: string; + protected pageIcon: Icon; + protected showReadonlyHostname: boolean; + protected maxWidth: "md" | "3xl"; + + protected theme: string; + protected logo: Icon; + + constructor( + private router: Router, + private route: ActivatedRoute, + private i18nService: I18nService, + private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private themeStateService: ThemeStateService, + ) {} + + async ngOnInit(): Promise { + // Set the initial page data on load + this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data); + + // Listen for page changes and update the page data appropriately + this.listenForPageDataChanges(); + this.listenForServiceDataChanges(); + + this.theme = await firstValueFrom(this.themeStateService.selectedTheme$); + + if (this.theme === "dark") { + this.logo = ExtensionBitwardenLogoWhite; + } else { + this.logo = ExtensionBitwardenLogoPrimary; + } + } + + private listenForPageDataChanges() { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + // reset page data on page changes + tap(() => this.resetPageData()), + switchMap(() => this.route.firstChild?.data || null), + takeUntil(this.destroy$), + ) + .subscribe((firstChildRouteData: Data | null) => { + this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData); + }); + } + + private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) { + if (!firstChildRouteData) { + return; + } + + if (firstChildRouteData["pageTitle"] !== undefined) { + this.pageTitle = this.i18nService.t(firstChildRouteData["pageTitle"]); + } + + if (firstChildRouteData["pageSubtitle"] !== undefined) { + this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]); + } + + if (firstChildRouteData["pageIcon"] !== undefined) { + this.pageIcon = firstChildRouteData["pageIcon"]; + } + + this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); + this.maxWidth = firstChildRouteData["maxWidth"]; + + if (firstChildRouteData["showAcctSwitcher"] !== undefined) { + this.showAcctSwitcher = Boolean(firstChildRouteData["showAcctSwitcher"]); + } + + if (firstChildRouteData["showBackButton"] !== undefined) { + this.showBackButton = Boolean(firstChildRouteData["showBackButton"]); + } + + if (firstChildRouteData["showLogo"] !== undefined) { + this.showLogo = Boolean(firstChildRouteData["showLogo"]); + } + } + + private listenForServiceDataChanges() { + this.extensionAnonLayoutWrapperDataService + .anonLayoutWrapperData$() + .pipe(takeUntil(this.destroy$)) + .subscribe((data: ExtensionAnonLayoutWrapperData) => { + this.setAnonLayoutWrapperData(data); + }); + } + + private setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData) { + if (!data) { + return; + } + + if (data.pageTitle) { + this.pageTitle = this.i18nService.t(data.pageTitle); + } + + if (data.pageSubtitle) { + this.pageSubtitle = this.i18nService.t(data.pageSubtitle); + } + + if (data.pageIcon) { + this.pageIcon = data.pageIcon; + } + + if (data.showReadonlyHostname != null) { + this.showReadonlyHostname = data.showReadonlyHostname; + } + + if (data.showAcctSwitcher != null) { + this.showAcctSwitcher = data.showAcctSwitcher; + } + + if (data.showBackButton != null) { + this.showBackButton = data.showBackButton; + } + + if (data.showLogo != null) { + this.showLogo = data.showLogo; + } + } + + private resetPageData() { + this.pageTitle = null; + this.pageSubtitle = null; + this.pageIcon = null; + this.showReadonlyHostname = null; + this.showAcctSwitcher = null; + this.showBackButton = null; + this.showLogo = null; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts new file mode 100644 index 00000000000..c447ccffd78 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -0,0 +1,294 @@ +import { importProvidersFrom, Component } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; +import { + Meta, + StoryObj, + applicationConfig, + componentWrapperDecorator, + moduleMetadata, +} from "@storybook/angular"; +import { of } from "rxjs"; + +import { AnonLayoutWrapperDataService, LockIcon } from "@bitwarden/auth/angular"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + EnvironmentService, + Environment, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ThemeType } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ButtonModule, I18nMockService } from "@bitwarden/components"; + +import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon"; + +import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service"; +import { + ExtensionAnonLayoutWrapperComponent, + ExtensionAnonLayoutWrapperData, +} from "./extension-anon-layout-wrapper.component"; + +export default { + title: "Auth/Extension Anon Layout Wrapper", + component: ExtensionAnonLayoutWrapperComponent, +} as Meta; + +const decorators = (options: { + components: any[]; + routes: Routes; + applicationVersion?: string; + clientType?: ClientType; + hostName?: string; + themeType?: ThemeType; +}) => { + return [ + componentWrapperDecorator( + /** + * Applying a CSS transform makes a `position: fixed` element act like it is `position: relative` + * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 + */ + (story) => { + return /* HTML */ `
${story}
`; + }, + ({ globals }) => { + /** + * avoid a bug with the way that we render the same component twice in the same iframe and how + * that interacts with the router-outlet + */ + const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"]; + return { theme: themeOverride }; + }, + ), + moduleMetadata({ + declarations: options.components, + imports: [RouterModule, ButtonModule], + providers: [ + { + provide: AnonLayoutWrapperDataService, + useClass: ExtensionAnonLayoutWrapperDataService, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "test-user-id" as UserId, + name: "Test User 1", + email: "test@email.com", + emailVerified: true, + }), + }, + }, + { + provide: AuthService, + useValue: { + activeAccountStatus$: of(AuthenticationStatus.Unlocked), + }, + }, + { + provide: AvatarService, + useValue: { + avatarColor$: of("#ab134a"), + } as Partial, + }, + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => true, + }, + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getHostname: () => options.hostName || "storybook.bitwarden.com", + } as Partial), + } as Partial, + }, + { + provide: PlatformUtilsService, + useValue: { + getApplicationVersion: () => + Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"), + getClientType: () => options.clientType || ClientType.Web, + } as Partial, + }, + { + provide: ThemeStateService, + useValue: { + selectedTheme$: of(options.themeType || ThemeType.Light), + } as Partial, + }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + setAStrongPassword: "Set a strong password", + finishCreatingYourAccountBySettingAPassword: + "Finish creating your account by setting a password", + enterpriseSingleSignOn: "Enterprise single sign-on", + checkYourEmail: "Check your email", + loading: "Loading", + popOutNewWindow: "Pop out to a new window", + switchAccounts: "Switch accounts", + back: "Back", + activeAccount: "Active account", + }); + }, + }, + ], + }), + applicationConfig({ + providers: [importProvidersFrom(RouterModule.forRoot(options.routes))], + }), + ]; +}; + +type Story = StoryObj; + +// Default Example + +@Component({ + selector: "bit-default-primary-outlet-example-component", + template: "

Primary Outlet Example:
your primary component goes here

", +}) +class DefaultPrimaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-secondary-outlet-example-component", + template: "

Secondary Outlet Example:
your secondary component goes here

", +}) +class DefaultSecondaryOutletExampleComponent {} + +@Component({ + selector: "bit-default-env-selector-outlet-example-component", + template: "

Env Selector Outlet Example:
your env selector component goes here

", +}) +class DefaultEnvSelectorOutletExampleComponent {} + +export const DefaultContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [ + DefaultPrimaryOutletExampleComponent, + DefaultSecondaryOutletExampleComponent, + DefaultEnvSelectorOutletExampleComponent, + ], + routes: [ + { + path: "**", + redirectTo: "default-example", + pathMatch: "full", + }, + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "default-example", + data: {}, + children: [ + { + path: "", + component: DefaultPrimaryOutletExampleComponent, + }, + { + path: "", + component: DefaultSecondaryOutletExampleComponent, + outlet: "secondary", + }, + { + path: "", + component: DefaultEnvSelectorOutletExampleComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ], + }), +}; + +// Dynamic Content Example +const initialData: ExtensionAnonLayoutWrapperData = { + pageTitle: "setAStrongPassword", + pageSubtitle: "finishCreatingYourAccountBySettingAPassword", + pageIcon: LockIcon, + showAcctSwitcher: true, + showBackButton: true, + showLogo: true, +}; + +const changedData: ExtensionAnonLayoutWrapperData = { + pageTitle: "enterpriseSingleSignOn", + pageSubtitle: "checkYourEmail", + pageIcon: RegistrationCheckEmailIcon, + showAcctSwitcher: false, + showBackButton: false, + showLogo: false, +}; + +@Component({ + selector: "bit-dynamic-content-example-component", + template: ` + + `, +}) +export class DynamicContentExampleComponent { + initialData = true; + + constructor(private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService) {} + + toggleData() { + if (this.initialData) { + this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData); + } else { + this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData); + } + + this.initialData = !this.initialData; + } +} + +export const DynamicContentExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [DynamicContentExampleComponent], + routes: [ + { + path: "**", + redirectTo: "dynamic-content-example", + pathMatch: "full", + }, + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "dynamic-content-example", + data: initialData, + children: [ + { + path: "", + component: DynamicContentExampleComponent, + }, + ], + }, + ], + }, + ], + }), +}; diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts new file mode 100644 index 00000000000..569edaae978 --- /dev/null +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-bitwarden-logo.icon.ts @@ -0,0 +1,35 @@ +import { svgIcon } from "@bitwarden/components"; + +export const ExtensionBitwardenLogoPrimary = svgIcon` + + + +`; + +export const ExtensionBitwardenLogoWhite = svgIcon` + + + +`; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 2c7129db293..2fba41d17ad 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -15,6 +15,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; +import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -82,6 +83,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; +import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import MainBackground from "../../background/main.background"; @@ -521,6 +523,11 @@ const safeProviders: SafeProvider[] = [ useFactory: getBgService("taskSchedulerService"), deps: [], }), + safeProvider({ + provide: AnonLayoutWrapperDataService, + useClass: ExtensionAnonLayoutWrapperDataService, + deps: [], + }), ]; @NgModule({ diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 7559e35cdc3..1c082323b1d 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -106,7 +106,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.pageIcon = data.pageIcon; } - if (data.showReadonlyHostname) { + if (data.showReadonlyHostname != null) { this.showReadonlyHostname = data.showReadonlyHostname; } } diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 3af22a704fd..bd3de51c461 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -1,5 +1,6 @@
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.ts b/libs/auth/src/angular/anon-layout/anon-layout.component.ts index 966cbd3c0e1..19dafa732ab 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.ts @@ -26,6 +26,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { @Input() showReadonlyHostname: boolean; @Input() hideLogo: boolean = false; @Input() hideFooter: boolean = false; + @Input() decreaseTopPadding: boolean = false; /** * Max width of the layout content * diff --git a/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts b/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts index 43637d481f7..1f09d209445 100644 --- a/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts +++ b/libs/auth/src/angular/anon-layout/default-anon-layout-wrapper-data.service.ts @@ -4,7 +4,7 @@ import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component"; export class DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService { - private anonLayoutWrapperDataSubject = new Subject(); + protected anonLayoutWrapperDataSubject = new Subject(); setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void { this.anonLayoutWrapperDataSubject.next(data); diff --git a/libs/components/src/styles.scss b/libs/components/src/styles.scss index 7ddcb1b64b9..711a817bba7 100644 --- a/libs/components/src/styles.scss +++ b/libs/components/src/styles.scss @@ -52,3 +52,10 @@ $card-icons-base: "../../src/billing/images/cards/"; .sbdocs-preview pre.prismjs { color: white; } + +.sb-show-main app-current-account button { + border: none; + background-color: transparent; + padding-inline: 0px; + padding-block: 0px; +}