mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 09:13:33 +00:00
[CL-700] Move auth-owned layout components to UIF ownership (#15093)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
|
||||
|
||||
/**
|
||||
* A simple data service to allow any child components of the AnonLayoutWrapperComponent to override
|
||||
* page route data and dynamically control the data fed into the AnonLayoutComponent via the AnonLayoutWrapperComponent.
|
||||
*/
|
||||
export abstract class AnonLayoutWrapperDataService {
|
||||
/**
|
||||
*
|
||||
* @param data - The data to set on the AnonLayoutWrapperComponent to feed into the AnonLayoutComponent.
|
||||
*/
|
||||
abstract setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void;
|
||||
|
||||
/**
|
||||
* Reactively gets the current AnonLayoutWrapperData.
|
||||
*/
|
||||
abstract anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<auth-anon-layout
|
||||
[title]="pageTitle"
|
||||
[subtitle]="pageSubtitle"
|
||||
[icon]="pageIcon"
|
||||
[showReadonlyHostname]="showReadonlyHostname"
|
||||
[maxWidth]="maxWidth"
|
||||
[titleAreaMaxWidth]="titleAreaMaxWidth"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
|
||||
</auth-anon-layout>
|
||||
174
libs/components/src/anon-layout/anon-layout-wrapper.component.ts
Normal file
174
libs/components/src/anon-layout/anon-layout-wrapper.component.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
|
||||
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { Translation } from "../dialog";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
|
||||
export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
* The optional title of the page.
|
||||
* If a string is provided, it will be presented as is (ex: Organization name)
|
||||
* If a Translation object (supports placeholders) is provided, it will be translated
|
||||
*/
|
||||
pageTitle?: string | Translation | null;
|
||||
/**
|
||||
* The optional subtitle of the page.
|
||||
* If a string is provided, it will be presented as is (ex: user's email)
|
||||
* If a Translation object (supports placeholders) is provided, it will be translated
|
||||
*/
|
||||
pageSubtitle?: string | Translation | null;
|
||||
/**
|
||||
* The optional icon to display on the page.
|
||||
*/
|
||||
pageIcon?: Icon | null;
|
||||
/**
|
||||
* Optional flag to either show the optional environment selector (false) or just a readonly hostname (true).
|
||||
*/
|
||||
showReadonlyHostname?: boolean;
|
||||
/**
|
||||
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
|
||||
*/
|
||||
maxWidth?: "md" | "3xl";
|
||||
/**
|
||||
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
|
||||
*/
|
||||
titleAreaMaxWidth?: "md";
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: "anon-layout-wrapper.component.html",
|
||||
imports: [AnonLayoutComponent, RouterModule],
|
||||
})
|
||||
export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
protected pageTitle: string;
|
||||
protected pageSubtitle: string;
|
||||
protected pageIcon: Icon;
|
||||
protected showReadonlyHostname: boolean;
|
||||
protected maxWidth: "md" | "3xl";
|
||||
protected titleAreaMaxWidth: "md";
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private i18nService: I18nService,
|
||||
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// 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();
|
||||
}
|
||||
|
||||
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.handleStringOrTranslation(firstChildRouteData["pageTitle"]);
|
||||
}
|
||||
|
||||
if (firstChildRouteData["pageSubtitle"] !== undefined) {
|
||||
this.pageSubtitle = this.handleStringOrTranslation(firstChildRouteData["pageSubtitle"]);
|
||||
}
|
||||
|
||||
if (firstChildRouteData["pageIcon"] !== undefined) {
|
||||
this.pageIcon = firstChildRouteData["pageIcon"];
|
||||
}
|
||||
|
||||
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
|
||||
this.maxWidth = firstChildRouteData["maxWidth"];
|
||||
this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"];
|
||||
}
|
||||
|
||||
private listenForServiceDataChanges() {
|
||||
this.anonLayoutWrapperDataService
|
||||
.anonLayoutWrapperData$()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((data: AnonLayoutWrapperData) => {
|
||||
this.setAnonLayoutWrapperData(data);
|
||||
});
|
||||
}
|
||||
|
||||
private setAnonLayoutWrapperData(data: AnonLayoutWrapperData) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Null emissions are used to reset the page data as all fields are optional.
|
||||
|
||||
if (data.pageTitle !== undefined) {
|
||||
this.pageTitle =
|
||||
data.pageTitle !== null ? this.handleStringOrTranslation(data.pageTitle) : null;
|
||||
}
|
||||
|
||||
if (data.pageSubtitle !== undefined) {
|
||||
this.pageSubtitle =
|
||||
data.pageSubtitle !== null ? this.handleStringOrTranslation(data.pageSubtitle) : null;
|
||||
}
|
||||
|
||||
if (data.pageIcon !== undefined) {
|
||||
this.pageIcon = data.pageIcon !== null ? data.pageIcon : null;
|
||||
}
|
||||
|
||||
if (data.showReadonlyHostname !== undefined) {
|
||||
this.showReadonlyHostname = data.showReadonlyHostname;
|
||||
}
|
||||
|
||||
// Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
// when setting the page data from a service
|
||||
this.changeDetectorRef.detectChanges();
|
||||
}
|
||||
|
||||
private handleStringOrTranslation(value: string | Translation): string {
|
||||
if (typeof value === "string") {
|
||||
// If it's a string, return it as is
|
||||
return value;
|
||||
}
|
||||
|
||||
// If it's a Translation object, translate it
|
||||
return this.i18nService.t(value.key, ...(value.placeholders ?? []));
|
||||
}
|
||||
|
||||
private resetPageData() {
|
||||
this.pageTitle = null;
|
||||
this.pageSubtitle = null;
|
||||
this.pageIcon = null;
|
||||
this.showReadonlyHostname = null;
|
||||
this.maxWidth = null;
|
||||
this.titleAreaMaxWidth = null;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
24
libs/components/src/anon-layout/anon-layout-wrapper.mdx
Normal file
24
libs/components/src/anon-layout/anon-layout-wrapper.mdx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./anon-layout-wrapper.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# Anon Layout Wrapper
|
||||
|
||||
## Anon Layout Wrapper Component
|
||||
|
||||
The auth owned `AnonLayoutWrapperComponent` orchestrates routing configuration data and feeds it
|
||||
into the `AnonLayoutComponent`. See the `Anon Layout` storybook for full documentation on how to use
|
||||
the `AnonLayoutWrapperComponent`.
|
||||
|
||||
## Default Example with all 3 outlets used
|
||||
|
||||
<Story of={stories.DefaultContentExample} />
|
||||
|
||||
## Dynamic Anon Layout Wrapper Content Example
|
||||
|
||||
This example demonstrates a child component using the `DefaultAnonLayoutWrapperDataService` to
|
||||
dynamically set the content of the `AnonLayoutWrapperComponent`.
|
||||
|
||||
<Story of={stories.DynamicContentExample} />
|
||||
246
libs/components/src/anon-layout/anon-layout-wrapper.stories.ts
Normal file
246
libs/components/src/anon-layout/anon-layout-wrapper.stories.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
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 { ClientType } from "@bitwarden/common/enums";
|
||||
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 { ButtonModule } from "../button";
|
||||
import { LockIcon, RegistrationCheckEmailIcon } from "../icon/icons";
|
||||
import { I18nMockService } from "../utils";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
|
||||
import { DefaultAnonLayoutWrapperDataService } from "./default-anon-layout-wrapper-data.service";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Anon Layout Wrapper",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
} as Meta;
|
||||
|
||||
const decorators = (options: {
|
||||
components: any[];
|
||||
routes: Routes;
|
||||
applicationVersion?: string;
|
||||
clientType?: ClientType;
|
||||
hostName?: string;
|
||||
}) => {
|
||||
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 */ `<div class="tw-scale-100 ">${story}</div>`;
|
||||
},
|
||||
({ 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: DefaultAnonLayoutWrapperDataService,
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
environment$: of({
|
||||
getHostname: () => options.hostName || "storybook.bitwarden.com",
|
||||
} as Partial<Environment>),
|
||||
} as Partial<EnvironmentService>,
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
getApplicationVersion: () =>
|
||||
Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"),
|
||||
getClientType: () => options.clientType || ClientType.Web,
|
||||
} as Partial<PlatformUtilsService>,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
setAStrongPassword: "Set a strong password",
|
||||
appLogoLabel: "app logo label",
|
||||
finishCreatingYourAccountBySettingAPassword:
|
||||
"Finish creating your account by setting a password",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [importProvidersFrom(RouterModule.forRoot(options.routes))],
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
type Story = StoryObj<AnonLayoutWrapperComponent>;
|
||||
|
||||
// Default Example
|
||||
|
||||
@Component({
|
||||
selector: "bit-default-primary-outlet-example-component",
|
||||
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultPrimaryOutletExampleComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "bit-default-secondary-outlet-example-component",
|
||||
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultSecondaryOutletExampleComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "bit-default-env-selector-outlet-example-component",
|
||||
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultEnvSelectorOutletExampleComponent {}
|
||||
|
||||
export const DefaultContentExample: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: "<router-outlet></router-outlet>",
|
||||
}),
|
||||
decorators: decorators({
|
||||
components: [
|
||||
DefaultPrimaryOutletExampleComponent,
|
||||
DefaultSecondaryOutletExampleComponent,
|
||||
DefaultEnvSelectorOutletExampleComponent,
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "**",
|
||||
redirectTo: "default-example",
|
||||
pathMatch: "full",
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
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: AnonLayoutWrapperData = {
|
||||
pageTitle: {
|
||||
key: "setAStrongPassword",
|
||||
},
|
||||
pageSubtitle: {
|
||||
key: "finishCreatingYourAccountBySettingAPassword",
|
||||
},
|
||||
pageIcon: LockIcon,
|
||||
};
|
||||
|
||||
const changedData: AnonLayoutWrapperData = {
|
||||
pageTitle: {
|
||||
key: "enterpriseSingleSignOn",
|
||||
},
|
||||
pageSubtitle: "user@email.com (non-translated)",
|
||||
pageIcon: RegistrationCheckEmailIcon,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "bit-dynamic-content-example-component",
|
||||
template: `
|
||||
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
|
||||
`,
|
||||
standalone: false,
|
||||
})
|
||||
export class DynamicContentExampleComponent {
|
||||
initialData = true;
|
||||
|
||||
constructor(private anonLayoutWrapperDataService: AnonLayoutWrapperDataService) {}
|
||||
|
||||
toggleData() {
|
||||
if (this.initialData) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData);
|
||||
} else {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData);
|
||||
}
|
||||
|
||||
this.initialData = !this.initialData;
|
||||
}
|
||||
}
|
||||
|
||||
export const DynamicContentExample: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: "<router-outlet></router-outlet>",
|
||||
}),
|
||||
decorators: decorators({
|
||||
components: [DynamicContentExampleComponent],
|
||||
routes: [
|
||||
{
|
||||
path: "**",
|
||||
redirectTo: "dynamic-content-example",
|
||||
pathMatch: "full",
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "dynamic-content-example",
|
||||
data: initialData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: DynamicContentExampleComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
62
libs/components/src/anon-layout/anon-layout.component.html
Normal file
62
libs/components/src/anon-layout/anon-layout.component.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<main
|
||||
class="tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-px-6 tw-py-4 tw-text-main"
|
||||
[ngClass]="{
|
||||
'tw-min-h-screen': clientType === 'web',
|
||||
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
|
||||
}"
|
||||
>
|
||||
<a
|
||||
*ngIf="!hideLogo"
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="tw-text-center tw-mb-4 sm:tw-mb-6"
|
||||
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
|
||||
>
|
||||
<div *ngIf="!hideIcon" class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
|
||||
<bit-icon [icon]="icon"></bit-icon>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="title">
|
||||
<!-- Small screens -->
|
||||
<h1 bitTypography="h3" class="tw-mt-2 sm:tw-hidden">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<!-- Medium to Larger screens -->
|
||||
<h1 bitTypography="h2" class="tw-mt-2 tw-hidden sm:tw-block">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="subtitle" class="tw-text-sm sm:tw-text-base">{{ subtitle }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-grow tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
|
||||
[ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }"
|
||||
>
|
||||
<div
|
||||
class="tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
|
||||
<footer *ngIf="!hideFooter" class="tw-text-center tw-mt-4 sm:tw-mt-6">
|
||||
<div *ngIf="showReadonlyHostname" bitTypography="body2">
|
||||
{{ "accessing" | i18n }} {{ hostname }}
|
||||
</div>
|
||||
<ng-container *ngIf="!showReadonlyHostname">
|
||||
<ng-content select="[slot=environment-selector]"></ng-content>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!hideYearAndVersion">
|
||||
<div bitTypography="body2">© {{ year }} Bitwarden Inc.</div>
|
||||
<div bitTypography="body2">{{ version }}</div>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</main>
|
||||
85
libs/components/src/anon-layout/anon-layout.component.ts
Normal file
85
libs/components/src/anon-layout/anon-layout.component.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { IconModule, Icon } from "../icon";
|
||||
import { BitwardenLogo, BitwardenShield } from "../icon/icons";
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
@Component({
|
||||
selector: "auth-anon-layout",
|
||||
templateUrl: "./anon-layout.component.html",
|
||||
imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule],
|
||||
})
|
||||
export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
// AnonLayout should take up full height of parent container for proper footer placement.
|
||||
return ["tw-h-full"];
|
||||
}
|
||||
|
||||
@Input() title: string;
|
||||
@Input() subtitle: string;
|
||||
@Input() icon: Icon;
|
||||
@Input() showReadonlyHostname: boolean;
|
||||
@Input() hideLogo: boolean = false;
|
||||
@Input() hideFooter: boolean = false;
|
||||
@Input() hideIcon: boolean = false;
|
||||
|
||||
/**
|
||||
* Max width of the title area content
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
@Input() titleAreaMaxWidth?: "md";
|
||||
|
||||
/**
|
||||
* Max width of the layout content
|
||||
*
|
||||
* @default 'md'
|
||||
*/
|
||||
@Input() maxWidth: "md" | "3xl" = "md";
|
||||
|
||||
protected logo = BitwardenLogo;
|
||||
protected year = "2024";
|
||||
protected clientType: ClientType;
|
||||
protected hostname: string;
|
||||
protected version: string;
|
||||
|
||||
protected hideYearAndVersion = false;
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {
|
||||
this.year = new Date().getFullYear().toString();
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
this.hideYearAndVersion = this.clientType !== ClientType.Web;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.maxWidth = this.maxWidth ?? "md";
|
||||
this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? null;
|
||||
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
|
||||
this.version = await this.platformUtilsService.getApplicationVersion();
|
||||
|
||||
// If there is no icon input, then use the default icon
|
||||
if (this.icon == null) {
|
||||
this.icon = BitwardenShield;
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.maxWidth) {
|
||||
this.maxWidth = changes.maxWidth.currentValue ?? "md";
|
||||
}
|
||||
}
|
||||
}
|
||||
168
libs/components/src/anon-layout/anon-layout.mdx
Normal file
168
libs/components/src/anon-layout/anon-layout.mdx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./anon-layout.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
# AnonLayout Component
|
||||
|
||||
The AnonLayoutComponent is to be used primarily for unauthenticated pages\*, where we don't know who
|
||||
the user is.
|
||||
|
||||
\*There will be a few exceptions to this—that is, AnonLayout will also be used for the Unlock
|
||||
and View Send pages.
|
||||
|
||||
---
|
||||
|
||||
### Incorrect Usage ❌
|
||||
|
||||
The AnonLayoutComponent is **not** to be implemented by every component that uses it in that
|
||||
component's template directly. For example, if you have a component template called
|
||||
`example.component.html`, and you want it to use the AnonLayoutComponent, you will **not** be
|
||||
writing:
|
||||
|
||||
```html
|
||||
<!-- File: example.component.html -->
|
||||
|
||||
<auth-anon-layout>
|
||||
<div>Example component content</div>
|
||||
</auth-anon-layout>
|
||||
```
|
||||
|
||||
### Correct Usage ✅
|
||||
|
||||
Instead the AnonLayoutComponent is implemented solely in the router via routable composition, which
|
||||
gives us the advantages of nested routes in Angular.
|
||||
|
||||
To allow for routable composition, Auth also provides an AnonLayout**Wrapper**Component which embeds
|
||||
the AnonLayoutComponent.
|
||||
|
||||
For clarity:
|
||||
|
||||
- AnonLayoutComponent = the base, Auth-owned library component - `<auth-anon-layout>`
|
||||
- AnonLayout**Wrapper**Component = the wrapper to be used in client routing modules
|
||||
|
||||
The AnonLayout**Wrapper**Component embeds the AnonLayoutComponent along with the router outlets:
|
||||
|
||||
```html
|
||||
<!-- File: anon-layout-wrapper.component.html -->
|
||||
|
||||
<auth-anon-layout
|
||||
[title]="pageTitle"
|
||||
[subtitle]="pageSubtitle"
|
||||
[icon]="pageIcon"
|
||||
[showReadonlyHostname]="showReadonlyHostname"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
|
||||
</auth-anon-layout>
|
||||
```
|
||||
|
||||
To implement, the developer does not need to work with the base AnonLayoutComponent directly. The
|
||||
devoloper simply uses the AnonLayout**Wrapper**Component in `oss-routing.module.ts` (for Web, for
|
||||
example) to construct the page via routable composition:
|
||||
|
||||
```typescript
|
||||
// File: oss-routing.module.ts
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
|
||||
|
||||
{
|
||||
path: "",
|
||||
component: AnonLayoutWrapperComponent, // Wrapper component
|
||||
children: [
|
||||
{
|
||||
path: "sample-route", // replace with your route
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: MyPrimaryComponent, // replace with your component
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: MySecondaryComponent, // replace with your component (or remove this secondary outlet object entirely if not needed)
|
||||
outlet: "secondary",
|
||||
},
|
||||
],
|
||||
data: {
|
||||
pageTitle: "logIn", // example of a translation key from messages.json
|
||||
pageSubtitle: "loginWithMasterPassword", // example of a translation key from messages.json
|
||||
pageIcon: LockIcon, // example of an icon to pass in
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
(Notice that you can optionally add an `outlet: "secondary"` if you want to project secondary
|
||||
content below the primary content).
|
||||
|
||||
If the AnonLayout**Wrapper**Component is already being used in your client's routing module, then
|
||||
your work will be as simple as just adding another child route under the `children` array.
|
||||
|
||||
<br />
|
||||
|
||||
### Data Properties
|
||||
|
||||
Routes that use the AnonLayou**tWrapper**Component can take several unique data properties defined
|
||||
in the `AnonLayoutWrapperData` interface:
|
||||
|
||||
- For the `pageTitle` and `pageSubtitle` - pass in a translation key from `messages.json`.
|
||||
- For the `pageIcon` - import an icon (of type `Icon`) into the router file and use the icon
|
||||
directly.
|
||||
- `showReadonlyHostname` - set to `true` if you want to show the hostname in the footer (ex:
|
||||
"Accessing bitwarden.com")
|
||||
|
||||
All of these properties are optional.
|
||||
|
||||
```typescript
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
|
||||
|
||||
{
|
||||
// ...
|
||||
data: {
|
||||
pageTitle: "logIn",
|
||||
pageSubtitle: "loginWithMasterPassword",
|
||||
pageIcon: LockIcon,
|
||||
showReadonlyHostname: true,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Selector
|
||||
|
||||
For some routes, you may want to display the environment selector in the footer of the
|
||||
AnonLayoutComponent. To do so, add the relevant environment selector (Web or Libs version, depending
|
||||
on your client) as a component with `outlet: "environment-selector"`.
|
||||
|
||||
```javascript
|
||||
// File: oss-routing.module.ts
|
||||
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular";
|
||||
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
|
||||
|
||||
{
|
||||
path: "",
|
||||
component: AnonLayoutWrapperComponent,
|
||||
children: [
|
||||
{
|
||||
path: "sample-route",
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: MyPrimaryComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: EnvironmentSelectorComponent, // use Web or Libs component depending on your client
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
],
|
||||
// ...
|
||||
},
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<Story of={stories.WithSecondaryContent} />
|
||||
228
libs/components/src/anon-layout/anon-layout.stories.ts
Normal file
228
libs/components/src/anon-layout/anon-layout.stories.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } 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 { ButtonModule } from "../button";
|
||||
import { LockIcon } from "../icon/icons";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
|
||||
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
|
||||
getClientType = () => ClientType.Web;
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Anon Layout",
|
||||
component: AnonLayoutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule, RouterModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useClass: MockPlatformUtilsService,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
accessing: "Accessing",
|
||||
appLogoLabel: "app logo label",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
environment$: new BehaviorSubject({
|
||||
getHostname() {
|
||||
return "bitwarden.com";
|
||||
},
|
||||
}).asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { queryParams: of({}) },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
title: "The Page Title",
|
||||
subtitle: "The subtitle (optional)",
|
||||
showReadonlyHostname: true,
|
||||
icon: LockIcon,
|
||||
hideLogo: false,
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<AnonLayoutComponent>;
|
||||
|
||||
export const WithPrimaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithSecondaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
// Notice that slot="secondary" is requred to project any secondary content.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
|
||||
<div slot="secondary" class="text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithLongContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
|
||||
</div>
|
||||
|
||||
<div slot="secondary" class="text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est? </p>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithThinPrimaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div class="text-center">Lorem ipsum</div>
|
||||
|
||||
<div slot="secondary" class="text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [icon]="icon" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideIcon]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideLogo: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const HideFooter: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithTitleAreaMaxWidth: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'",
|
||||
subtitle:
|
||||
"This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?",
|
||||
},
|
||||
template: `
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [titleAreaMaxWidth]="'md'">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutWrapperData } from "./anon-layout-wrapper.component";
|
||||
|
||||
export class DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService {
|
||||
protected anonLayoutWrapperDataSubject = new Subject<AnonLayoutWrapperData>();
|
||||
|
||||
setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void {
|
||||
this.anonLayoutWrapperDataSubject.next(data);
|
||||
}
|
||||
|
||||
anonLayoutWrapperData$(): Observable<AnonLayoutWrapperData> {
|
||||
return this.anonLayoutWrapperDataSubject.asObservable();
|
||||
}
|
||||
}
|
||||
4
libs/components/src/anon-layout/index.ts
Normal file
4
libs/components/src/anon-layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./anon-layout-wrapper-data.service";
|
||||
export * from "./anon-layout-wrapper.component";
|
||||
export * from "./anon-layout.component";
|
||||
export * from "./default-anon-layout-wrapper-data.service";
|
||||
Reference in New Issue
Block a user