1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-25871] updated phishing warning UI (#16748)

* refactor phishing-warning.component

* add hideBackground input to anon-layout component

* add icon tile component to CL

* add storybook story; fix binding bug in template

* export icon-tile from CL

* update design of phishing warning page

* revert icon button to use string type; add comment to icon scss

* update callout to allow no icon/title on all variants

* update phishing warning styles

* fix defects

* crowdin messages cannot be changed, they must be replaced

* add global css override

* add phishing help link

* update icon used in tile

* tweak styles
This commit is contained in:
Will Martin
2025-10-15 10:32:02 -04:00
committed by GitHub
parent fdee84214a
commit 0713f90a06
23 changed files with 623 additions and 68 deletions

View File

@@ -5579,17 +5579,37 @@
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"phishingPageTitle":{
"message": "Phishing website"
"phishingPageTitleV2":{
"message": "Phishing attempt detected"
},
"phishingPageCloseTab": {
"message": "Close tab"
"phishingPageSummary": {
"message": "The site you are attempting to visit is a known malicious site and a security risk."
},
"phishingPageContinue": {
"message": "Continue"
"phishingPageCloseTabV2": {
"message": "Close this tab"
},
"phishingPageLearnWhy": {
"message": "Why are you seeing this?"
"phishingPageContinueV2": {
"message": "Continue to this site (not recommended)"
},
"phishingPageExplanation1": {
"message": "This site was found in ",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this."
},
"phishingPageExplanation2": {
"message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this."
},
"phishingPageLearnMore" : {
"message": "Learn more about phishing detection"
},
"protectedBy": {
"message": "Protected by $PRODUCT$",
"placeholders": {
"product": {
"content": "$1",
"example": "Bitwarden Phishing Blocker"
}
}
},
"hasItemsVaultNudgeBodyOne": {
"message": "Autofill items for the current page"

View File

@@ -1,4 +0,0 @@
<span>{{ "phishingPageLearnWhy"| i18n}}</span>
<a href="http://bitwarden.com/help/phishing-blocked/" bitLink block buttonType="primary">
{{ "learnMore" | i18n }}
</a>

View File

@@ -1,13 +1,46 @@
<div class="tw-flex tw-flex-col tw-gap-2">
<bit-form-field>
<bit-label>{{ "phishingPageTitle" | i18n }}</bit-label>
<input bitInput disabled type="text" [value]="phishingHost" />
</bit-form-field>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-gap-4 tw-items-baseline">
<bit-icon-tile size="large" icon="bwi-exclamation-triangle" variant="danger"></bit-icon-tile>
<h1 bitTypography="h2" noMargin class="!tw-mb-0">{{ "phishingPageTitleV2" | i18n }}</h1>
</div>
<button type="button" (click)="closeTab()" bitButton buttonType="primary">
{{ "phishingPageCloseTab" | i18n }}
</button>
<button type="button" (click)="continueAnyway()" bitButton buttonType="danger">
{{ "phishingPageContinue" | i18n }}
</button>
<hr class="!tw-mt-6 !tw-mb-4 !tw-border-secondary-100" />
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
<span class="tw-font-mono">{{ phishingHost$ | async }}</span>
</bit-callout>
<bit-callout class="tw-mt-2" [icon]="null" type="default">
<p bitTypography="body2">
{{ "phishingPageExplanation1" | i18n }}<b>Phishing.Database</b
>{{ "phishingPageExplanation2" | i18n }}
</p>
<a
bitLink
linkType="primary"
rel="noreferrer"
target="_blank"
href="https://bitwarden.com/help/phishing-blocked/"
>
{{ "phishingPageLearnMore" | i18n }}<i class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-callout>
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center tw-mt-2">
<button type="button" (click)="closeTab()" bitButton buttonType="primary" [block]="true">
{{ "phishingPageCloseTabV2" | i18n }}
</button>
<button
class="tw-text-sm"
type="button"
(click)="continueAnyway()"
bitLink
linkType="secondary"
>
{{ "phishingPageContinueV2" | i18n }}
</button>
</div>
</div>

View File

@@ -1,10 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { CommonModule } from "@angular/common";
// eslint-disable-next-line no-restricted-imports
import { Component, OnDestroy } from "@angular/core";
import { Component, inject } from "@angular/core";
// eslint-disable-next-line no-restricted-imports
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -13,12 +13,16 @@ import {
CheckboxModule,
FormFieldModule,
IconModule,
IconTileComponent,
LinkModule,
CalloutComponent,
TypographyModule,
} from "@bitwarden/components";
import { PhishingDetectionService } from "../services/phishing-detection.service";
@Component({
selector: "dirt-phishing-warning",
standalone: true,
templateUrl: "phishing-warning.component.html",
imports: [
@@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service
CheckboxModule,
ButtonModule,
RouterModule,
IconTileComponent,
CalloutComponent,
TypographyModule,
],
})
export class PhishingWarning implements OnDestroy {
phishingHost = "";
private destroy$ = new Subject<void>();
constructor(private activatedRoute: ActivatedRoute) {
this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.phishingHost = params.get("phishingHost") || "";
});
}
export class PhishingWarning {
private activatedRoute = inject(ActivatedRoute);
protected phishingHost$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingHost") || ""),
);
async closeTab() {
await PhishingDetectionService.requestClosePhishingWarningPage();
@@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy {
async continueAnyway() {
await PhishingDetectionService.requestContinueToDangerousUrl();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,137 @@
// TODO: This needs to be dealt with by moving this folder or updating the lint rule.
/* eslint-disable no-restricted-imports */
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import { DeactivatedOrg } from "@bitwarden/assets/svg";
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 { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
import { PhishingWarning } from "./phishing-warning.component";
import { ProtectedByComponent } from "./protected-by-component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
getClientType = () => ClientType.Web;
}
/**
* Helper function to create ActivatedRoute mock with query parameters
*/
function mockActivatedRoute(queryParams: Record<string, string>) {
return {
provide: ActivatedRoute,
useValue: {
queryParamMap: of({
get: (key: string) => queryParams[key] || null,
}),
queryParams: of(queryParams),
},
};
}
type StoryArgs = {
phishingHost: string;
};
export default {
title: "Browser/DIRT/Phishing Warning",
component: PhishingWarning,
decorators: [
moduleMetadata({
imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
{
provide: I18nService,
useFactory: () =>
new I18nMockService({
accessing: "Accessing",
appLogoLabel: "Bitwarden logo",
phishingPageTitleV2: "Phishing attempt detected",
phishingPageCloseTabV2: "Close this tab",
phishingPageSummary:
"The site you are attempting to visit is a known malicious site and a security risk.",
phishingPageContinueV2: "Continue to this site (not recommended)",
phishingPageExplanation1: "This site was found in ",
phishingPageExplanation2:
", an open-source list of known phishing sites used for stealing personal and sensitive information.",
phishingPageLearnMore: "Learn more about phishing detection",
protectedBy: (product) => `Protected by ${product}`,
learnMore: "Learn more",
danger: "error",
}),
},
{
provide: EnvironmentService,
useValue: {
environment$: new BehaviorSubject({
getHostname() {
return "bitwarden.com";
},
}).asObservable(),
},
},
mockActivatedRoute({ phishingHost: "malicious-example.com" }),
],
}),
],
render: (args) => ({
props: args,
template: /*html*/ `
<auth-anon-layout
[hideIcon]="true"
[hideBackgroundIllustration]="true"
>
<dirt-phishing-warning></dirt-phishing-warning>
<dirt-phishing-protected-by slot="secondary"></dirt-phishing-protected-by>
</auth-anon-layout>
`,
}),
argTypes: {
phishingHost: {
control: "text",
description: "The suspicious host that was blocked",
},
},
args: {
phishingHost: "malicious-example.com",
pageIcon: DeactivatedOrg,
},
} satisfies Meta<StoryArgs & { pageIcon: any }>;
type Story = StoryObj<StoryArgs & { pageIcon: any }>;
export const Default: Story = {
args: {
phishingHost: "malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })],
}),
],
};
export const LongHostname: Story = {
args: {
phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [
mockActivatedRoute({
phishingHost:
"very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
}),
],
}),
],
};

View File

@@ -0,0 +1 @@
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>

View File

@@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule } from "@bitwarden/components";
import { ButtonModule, LinkModule } from "@bitwarden/components";
@Component({
selector: "dirt-phishing-protected-by",
standalone: true,
templateUrl: "learn-more-component.html",
imports: [CommonModule, CommonModule, JslibModule, ButtonModule],
templateUrl: "protected-by-component.html",
imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule],
})
export class LearnMoreComponent {
constructor() {}
}
export class ProtectedByComponent {}

View File

@@ -116,15 +116,15 @@ export class PhishingDetectionService {
/**
* Sends a message to the phishing detection service to close the warning page
*/
static requestClosePhishingWarningPage(): void {
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
static async requestClosePhishingWarningPage() {
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
}
/**
* Sends a message to the phishing detection service to continue to the caught url
*/
static async requestContinueToDangerousUrl() {
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
}
/**

View File

@@ -24,7 +24,6 @@ import {
VaultIcon,
LockIcon,
TwoFactorAuthSecurityKeyIcon,
DeactivatedOrg,
} from "@bitwarden/assets/svg";
import {
LoginComponent,
@@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component";
import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component";
import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
@@ -718,14 +717,13 @@ const routes: Routes = [
},
{
path: "",
component: LearnMoreComponent,
component: ProtectedByComponent,
outlet: "secondary",
},
],
data: {
pageIcon: DeactivatedOrg,
pageTitle: "Bitwarden blocked it!",
pageSubtitle: "Bitwarden blocked a known phishing site from loading.",
hideIcon: true,
hideBackgroundIllustration: true,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
},

View File

@@ -382,7 +382,7 @@ app-root {
}
}
main:not(popup-page main) {
main:not(popup-page main):not(auth-anon-layout main) {
position: absolute;
top: 44px;
bottom: 0;

View File

@@ -100,6 +100,7 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default;
}
// For new icons - add their glyph name and value to the map below
// Also add to `libs/components/src/shared/icon.ts`
$icons: (
"angle-down": "\e900",
"angle-left": "\e901",

View File

@@ -6,6 +6,7 @@
[maxWidth]="maxWidth"
[hideCardWrapper]="hideCardWrapper"
[hideIcon]="hideIcon"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>

View File

@@ -44,6 +44,10 @@ export interface AnonLayoutWrapperData {
* Hide the card that wraps the default content. Defaults to false.
*/
hideCardWrapper?: boolean;
/**
* Hides the background illustration. Defaults to false.
*/
hideBackgroundIllustration?: boolean;
}
@Component({
@@ -60,6 +64,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
protected maxWidth?: AnonLayoutMaxWidth | null;
protected hideCardWrapper?: boolean | null;
protected hideIcon?: boolean | null;
protected hideBackgroundIllustration?: boolean | null;
constructor(
private router: Router,
@@ -117,6 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"];
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]);
}
private listenForServiceDataChanges() {
@@ -157,6 +163,10 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.hideCardWrapper = data.hideCardWrapper;
}
if (data.hideBackgroundIllustration !== undefined) {
this.hideBackgroundIllustration = data.hideBackgroundIllustration;
}
if (data.hideIcon !== undefined) {
this.hideIcon = data.hideIcon;
}
@@ -188,5 +198,6 @@ export class AnonLayoutWrapperComponent implements OnInit {
this.maxWidth = null;
this.hideCardWrapper = null;
this.hideIcon = null;
this.hideBackgroundIllustration = null;
}
}

View File

@@ -68,16 +68,18 @@
</ng-container>
</footer>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
</main>
<ng-template #defaultContent>

View File

@@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
readonly hideFooter = input<boolean>(false);
readonly hideIcon = input<boolean>(false);
readonly hideCardWrapper = input<boolean>(false);
readonly hideBackgroundIllustration = input<boolean>(false);
/**
* Max width of the anon layout title, subtitle, and content areas.

View File

@@ -79,6 +79,7 @@ export default {
[hideIcon]="hideIcon"
[hideLogo]="hideLogo"
[hideFooter]="hideFooter"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<ng-container [ngSwitch]="contentLength">
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-bold">Thin Content</div></div>
@@ -125,6 +126,7 @@ export default {
hideIcon: { control: "boolean" },
hideLogo: { control: "boolean" },
hideFooter: { control: "boolean" },
hideBackgroundIllustration: { control: "boolean" },
contentLength: {
control: "radio",
@@ -145,6 +147,7 @@ export default {
hideIcon: false,
hideLogo: false,
hideFooter: false,
hideBackgroundIllustration: false,
contentLength: "normal",
showSecondary: false,
},
@@ -221,6 +224,10 @@ export const NoFooter: Story = {
args: { hideFooter: true },
};
export const NoBackgroundIllustration: Story = {
args: { hideBackgroundIllustration: true },
};
export const ReadonlyHostname: Story = {
args: { showReadonlyHostname: true },
};
@@ -234,5 +241,6 @@ export const MinimalState: Story = {
hideIcon: true,
hideLogo: true,
hideFooter: true,
hideBackgroundIllustration: true,
},
};

View File

@@ -36,11 +36,17 @@ let nextId = 0;
export class CalloutComponent {
readonly type = input<CalloutTypes>("info");
readonly icon = input<string>();
readonly title = input<string>();
readonly title = input<string | null>();
readonly useAlertRole = input(false);
readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]);
readonly iconComputed = computed(() =>
this.icon() === undefined ? defaultIcon[this.type()] : this.icon(),
);
readonly titleComputed = computed(() => {
const title = this.title();
if (title === null) {
return undefined;
}
const type = this.type();
if (title == null && defaultI18n[type] != null) {
return this.i18nService.t(defaultI18n[type]);

View File

@@ -0,0 +1,7 @@
<div
[ngClass]="containerClasses()"
[attr.aria-label]="ariaLabel()"
[attr.role]="ariaLabel() ? 'img' : null"
>
<i [ngClass]="iconClasses()" aria-hidden="true"></i>
</div>

View File

@@ -0,0 +1,111 @@
import { NgClass } from "@angular/common";
import { Component, computed, input } from "@angular/core";
import { BitwardenIcon } from "../shared/icon";
export type IconTileVariant = "primary" | "success" | "warning" | "danger" | "muted";
export type IconTileSize = "small" | "default" | "large";
export type IconTileShape = "square" | "circle";
const variantStyles: Record<IconTileVariant, string[]> = {
primary: ["tw-bg-primary-100", "tw-text-primary-700"],
success: ["tw-bg-success-100", "tw-text-success-700"],
warning: ["tw-bg-warning-100", "tw-text-warning-700"],
danger: ["tw-bg-danger-100", "tw-text-danger-700"],
muted: ["tw-bg-secondary-100", "tw-text-secondary-700"],
};
const sizeStyles: Record<IconTileSize, { container: string[]; icon: string[] }> = {
small: {
container: ["tw-w-6", "tw-h-6"],
icon: ["tw-text-sm"],
},
default: {
container: ["tw-w-8", "tw-h-8"],
icon: ["tw-text-base"],
},
large: {
container: ["tw-w-10", "tw-h-10"],
icon: ["tw-text-lg"],
},
};
const shapeStyles: Record<IconTileShape, Record<IconTileSize, string[]>> = {
square: {
small: ["tw-rounded"],
default: ["tw-rounded-md"],
large: ["tw-rounded-lg"],
},
circle: {
small: ["tw-rounded-full"],
default: ["tw-rounded-full"],
large: ["tw-rounded-full"],
},
};
/**
* Icon tiles are static containers that display an icon with a colored background.
* They are similar to icon buttons but are not interactive and are used for visual
* indicators, status representations, or decorative elements.
*
* Use icon tiles to:
* - Display status or category indicators
* - Represent different types of content
* - Create visual hierarchy in lists or cards
* - Show app or service icons in a consistent format
*/
@Component({
selector: "bit-icon-tile",
templateUrl: "icon-tile.component.html",
imports: [NgClass],
})
export class IconTileComponent {
/**
* The BWI icon name
*/
readonly icon = input.required<BitwardenIcon>();
/**
* The visual theme of the icon tile
*/
readonly variant = input<IconTileVariant>("primary");
/**
* The size of the icon tile
*/
readonly size = input<IconTileSize>("default");
/**
* The shape of the icon tile
*/
readonly shape = input<IconTileShape>("square");
/**
* Optional aria-label for accessibility when the icon has semantic meaning
*/
readonly ariaLabel = input<string>();
protected readonly containerClasses = computed(() => {
const variant = this.variant();
const size = this.size();
const shape = this.shape();
return [
"tw-inline-flex",
"tw-items-center",
"tw-justify-center",
"tw-flex-shrink-0",
...variantStyles[variant],
...sizeStyles[size].container,
...shapeStyles[shape][size],
];
});
protected readonly iconClasses = computed(() => {
const size = this.size();
return ["bwi", this.icon(), ...sizeStyles[size].icon];
});
}

View File

@@ -0,0 +1,114 @@
import { Meta, StoryObj } from "@storybook/angular";
import { BITWARDEN_ICONS } from "../shared/icon";
import { IconTileComponent } from "./icon-tile.component";
export default {
title: "Component Library/Icon Tile",
component: IconTileComponent,
args: {
icon: "bwi-star",
variant: "primary",
size: "default",
shape: "square",
},
argTypes: {
variant: {
options: ["primary", "success", "warning", "danger", "muted"],
control: { type: "select" },
},
size: {
options: ["small", "default", "large"],
control: { type: "select" },
},
shape: {
options: ["square", "circle"],
control: { type: "select" },
},
icon: {
options: BITWARDEN_ICONS,
control: { type: "select" },
},
ariaLabel: {
control: { type: "text" },
},
},
parameters: {
design: {
type: "figma",
url: "https://atlassian.design/components/icon/icon-tile/examples",
},
},
} as Meta<IconTileComponent>;
type Story = StoryObj<IconTileComponent>;
export const Default: Story = {};
export const AllVariants: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center tw-flex-wrap">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-collection" variant="primary"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Primary</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-check-circle" variant="success"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Success</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-exclamation-triangle" variant="warning"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Warning</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-error" variant="danger"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Danger</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-question-circle" variant="muted"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Muted</span>
</div>
</div>
`,
}),
};
export const AllSizes: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="small"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Small</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="default"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Default</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="large"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Large</span>
</div>
</div>
`,
}),
};
export const AllShapes: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-user" variant="primary" shape="square"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Square</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-user" variant="primary" shape="circle"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Circle</span>
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1 @@
export * from "./icon-tile.component";

View File

@@ -21,6 +21,7 @@ export * from "./drawer";
export * from "./form-field";
export * from "./icon-button";
export * from "./icon";
export * from "./icon-tile";
export * from "./input";
export * from "./item";
export * from "./layout";

View File

@@ -0,0 +1,110 @@
/**
* Array of available Bitwarden Web Icons (bwi) font names.
* These correspond to the actual icon names defined in the bwi-font.
* This array serves as the single source of truth for all available icons.
*/
export const BITWARDEN_ICONS = [
"bwi-angle-down",
"bwi-angle-left",
"bwi-angle-right",
"bwi-angle-up",
"bwi-archive",
"bwi-bell",
"bwi-billing",
"bwi-bitcoin",
"bwi-browser",
"bwi-browser-alt",
"bwi-brush",
"bwi-bug",
"bwi-business",
"bwi-camera",
"bwi-check",
"bwi-check-circle",
"bwi-cli",
"bwi-clock",
"bwi-clone",
"bwi-close",
"bwi-cog",
"bwi-cog-f",
"bwi-collection",
"bwi-collection-shared",
"bwi-credit-card",
"bwi-dashboard",
"bwi-desktop",
"bwi-dollar",
"bwi-down-solid",
"bwi-download",
"bwi-drag-and-drop",
"bwi-ellipsis-h",
"bwi-ellipsis-v",
"bwi-envelope",
"bwi-error",
"bwi-exclamation-triangle",
"bwi-external-link",
"bwi-eye",
"bwi-eye-slash",
"bwi-family",
"bwi-file",
"bwi-file-text",
"bwi-files",
"bwi-filter",
"bwi-folder",
"bwi-generate",
"bwi-globe",
"bwi-hashtag",
"bwi-id-card",
"bwi-import",
"bwi-info-circle",
"bwi-key",
"bwi-list",
"bwi-list-alt",
"bwi-lock",
"bwi-lock-encrypted",
"bwi-lock-f",
"bwi-minus-circle",
"bwi-mobile",
"bwi-msp",
"bwi-numbered-list",
"bwi-paperclip",
"bwi-passkey",
"bwi-paypal",
"bwi-pencil",
"bwi-pencil-square",
"bwi-plus",
"bwi-plus-circle",
"bwi-popout",
"bwi-provider",
"bwi-puzzle",
"bwi-question-circle",
"bwi-refresh",
"bwi-search",
"bwi-send",
"bwi-share",
"bwi-shield",
"bwi-sign-in",
"bwi-sign-out",
"bwi-sliders",
"bwi-spinner",
"bwi-star",
"bwi-star-f",
"bwi-sticky-note",
"bwi-tag",
"bwi-trash",
"bwi-undo",
"bwi-universal-access",
"bwi-unlock",
"bwi-up-down-btn",
"bwi-up-solid",
"bwi-user",
"bwi-user-monitor",
"bwi-users",
"bwi-vault",
"bwi-wireless",
"bwi-wrench",
] as const;
/**
* Type-safe icon names derived from the BITWARDEN_ICONS array.
* This ensures type safety while allowing runtime iteration and validation.
*/
export type BitwardenIcon = (typeof BITWARDEN_ICONS)[number];