1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-12273] Admin Console Integration Page (#11883)

* Integration page initial implementation

* replace placeholder integrations

* fix linting and tests

* fix locales

* update locale

* Change logos, add link to SCIM page

* refactor to standalone components, add integration filtering pipe

* refactor modules and imports. Remove hyperlink text from integration card template

* refactor i18n usage to be more generic

* Add storybooks

* fix tests

* minify svgs, include spec files in TS config, fix stories

* Update apps/web/src/locales/en/messages.json

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* fix imports, add static dir for stories

* Add darkmode svgs for integrations

* hide nav link for non enterprise orgs

* add router guard

* change rxjs selector

* Remove tailwind class causing style issues

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Brandon Treston
2024-12-05 10:09:40 -05:00
committed by GitHub
parent 6dc68b174b
commit c11f429ddb
54 changed files with 764 additions and 110 deletions

View File

@@ -1,6 +1,11 @@
<app-header></app-header>
<p bitTypography="body1">{{ "scimDescription" | i18n }}</p>
<p bitTypography="body1">
{{ "scimIntegrationDescription" | i18n }}
<a bitLink target="_blank" href="https://bitwarden.com/help/about-scim/"
><i class="bwi bwi-question-circle"></i
></a>
</p>
<div *ngIf="loading">
<i

View File

@@ -1,30 +0,0 @@
<div
class="tw-block tw-h-full tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-600 tw-relative tw-transition-all xl:tw-w-64 hover:tw-scale-105 focus-within:tw-outline-none focus-within:tw-ring focus-within:tw-ring-primary-700 focus-within:tw-ring-offset-2"
>
<div
class="tw-flex tw-h-36 tw-bg-secondary-100 tw-items-center tw-justify-center tw-py-2 tw-px-6 lg:tw-py-4 lg:tw-px-12"
>
<div class="tw-flex tw-items-center tw-justify-center tw-h-28 tw-w-28 lg:tw-w-40">
<img
#imageEle
[src]="image"
alt=""
class="tw-block tw-mx-auto tw-h-auto tw-max-w-full tw-max-h-full"
/>
</div>
</div>
<div class="tw-p-5">
<h3 class="tw-mb-4 tw-text-lg tw-font-semibold">{{ name }}</h3>
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-w-full after:tw-h-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
{{ linkText }}
</a>
<span *ngIf="showNewBadge()" bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
</div>
</div>

View File

@@ -1,174 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { BehaviorSubject } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "../../../../../../../libs/angular/src/services/injection-tokens";
import { ThemeType } from "../../../../../../../libs/common/src/platform/enums";
import { ThemeStateService } from "../../../../../../../libs/common/src/platform/theming/theme-state.service";
import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>;
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
beforeEach(async () => {
// reset system theme
systemTheme$.next(ThemeType.Light);
await TestBed.configureTestingModule({
declarations: [IntegrationCardComponent],
providers: [
{
provide: ThemeStateService,
useValue: { selectedTheme$: usersPreferenceTheme$ },
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: systemTheme$,
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(IntegrationCardComponent);
component = fixture.componentInstance;
component.name = "Integration Name";
component.image = "test-image.png";
component.linkText = "Get started with integration";
component.linkURL = "https://example.com/";
fixture.detectChanges();
});
it("assigns link href", () => {
const link = fixture.nativeElement.querySelector("a");
expect(link.href).toBe("https://example.com/");
});
it("renders card body", () => {
const name = fixture.nativeElement.querySelector("h3");
const link = fixture.nativeElement.querySelector("a");
expect(name.textContent).toBe("Integration Name");
expect(link.textContent.trim()).toBe("Get started with integration");
});
it("assigns external rel attribute", () => {
component.externalURL = true;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
expect(link.rel).toBe("noopener noreferrer");
});
describe("new badge", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2023-09-01"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows when expiration is in the future", () => {
component.newBadgeExpiration = "2023-09-02";
expect(component.showNewBadge()).toBe(true);
});
it("does not show when expiration is not set", () => {
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is in the past", () => {
component.newBadgeExpiration = "2023-08-31";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is today", () => {
component.newBadgeExpiration = "2023-09-01";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is invalid", () => {
component.newBadgeExpiration = "not-a-date";
expect(component.showNewBadge()).toBe(false);
});
});
describe("imageDarkMode", () => {
it("ignores theme changes when darkModeImage is not set", () => {
systemTheme$.next(ThemeType.Dark);
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
describe("user prefers the system theme", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("sets image src to imageDarkMode", () => {
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
it("sets image src to light mode image", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
describe("user prefers dark mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to dark mode", () => {
systemTheme$.next(ThemeType.Light); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
});
describe("user prefers light mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to light mode", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
systemTheme$.next(ThemeType.Dark); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
});
});

View File

@@ -1,93 +0,0 @@
import {
AfterViewInit,
Component,
ElementRef,
Inject,
Input,
OnDestroy,
ViewChild,
} from "@angular/core";
import { Observable, Subject, combineLatest, takeUntil } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
@Component({
selector: "sm-integration-card",
templateUrl: "./integration-card.component.html",
})
export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
private destroyed$: Subject<void> = new Subject();
@ViewChild("imageEle") imageEle: ElementRef<HTMLImageElement>;
@Input() name: string;
@Input() image: string;
@Input() imageDarkMode?: string;
@Input() linkText: string;
@Input() linkURL: string;
/** Adds relevant `rel` attribute to external links */
@Input() externalURL?: boolean;
/**
* Date of when the new badge should be hidden.
* When omitted, the new badge is never shown.
*
* @example "2024-12-31"
*/
@Input() newBadgeExpiration?: string;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
) {}
ngAfterViewInit() {
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
.pipe(takeUntil(this.destroyed$))
.subscribe(([theme, systemTheme]) => {
// When the card doesn't have a dark mode image, exit early
if (!this.imageDarkMode) {
return;
}
if (theme === ThemeType.System) {
// When the user's preference is the system theme,
// use the system theme to determine the image
const prefersDarkMode =
systemTheme === ThemeType.Dark || systemTheme === ThemeType.SolarizedDark;
this.imageEle.nativeElement.src = prefersDarkMode ? this.imageDarkMode : this.image;
} else if (theme === ThemeType.Dark || theme === ThemeType.SolarizedDark) {
// When the user's preference is dark mode, use the dark mode image
this.imageEle.nativeElement.src = this.imageDarkMode;
} else {
// Otherwise use the light mode image
this.imageEle.nativeElement.src = this.image;
}
});
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
/** Show the "new" badge when expiration is in the future */
showNewBadge() {
if (!this.newBadgeExpiration) {
return false;
}
const expirationDate = new Date(this.newBadgeExpiration);
// Do not show the new badge for invalid dates
if (isNaN(expirationDate.getTime())) {
return false;
}
return expirationDate > new Date();
}
}

View File

@@ -1,15 +0,0 @@
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none lg:tw-grid-cols-4 lg:tw-gap-10 lg:tw-w-auto"
>
<li *ngFor="let integration of integrations">
<sm-integration-card
[name]="integration.name"
[linkText]="integration.linkText"
[linkURL]="integration.linkURL"
[image]="integration.image"
[imageDarkMode]="integration.imageDarkMode"
[externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration"
></sm-integration-card>
</li>
</ul>

View File

@@ -1,81 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "../../../../../../../libs/angular/src/services/injection-tokens";
import { IntegrationType } from "../../../../../../../libs/common/src/enums";
import { ThemeType } from "../../../../../../../libs/common/src/platform/enums";
import { ThemeStateService } from "../../../../../../../libs/common/src/platform/theming/theme-state.service";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models/integration";
import { IntegrationGridComponent } from "./integration-grid.component";
describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const integrations: Integration[] = [
{
name: "Integration 1",
image: "test-image1.png",
linkText: "Get started with integration 1",
linkURL: "https://example.com/1",
type: IntegrationType.Integration,
},
{
name: "SDK 2",
image: "test-image2.png",
linkText: "View SDK 2",
linkURL: "https://example.com/2",
type: IntegrationType.SDK,
},
];
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [IntegrationGridComponent, IntegrationCardComponent],
providers: [
{
provide: ThemeStateService,
useValue: mock<ThemeStateService>(),
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeType.Light),
},
],
});
fixture = TestBed.createComponent(IntegrationGridComponent);
component = fixture.componentInstance;
component.integrations = integrations;
fixture.detectChanges();
});
it("lists all integrations", () => {
expect(component.integrations).toEqual(integrations);
const cards = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(cards.length).toBe(integrations.length);
});
it("assigns the correct attributes to IntegrationCardComponent", () => {
expect(component.integrations).toEqual(integrations);
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent))[1];
expect(card.componentInstance.name).toBe("SDK 2");
expect(card.componentInstance.image).toBe("test-image2.png");
expect(card.componentInstance.linkText).toBe("View SDK 2");
expect(card.componentInstance.linkURL).toBe("https://example.com/2");
});
it("assigns `externalURL` for SDKs", () => {
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(card[0].componentInstance.externalURL).toBe(false);
expect(card[1].componentInstance.externalURL).toBe(true);
});
});

View File

@@ -1,15 +0,0 @@
import { Component, Input } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { Integration } from "../models/integration";
@Component({
selector: "sm-integration-grid",
templateUrl: "./integration-grid.component.html",
})
export class IntegrationGridComponent {
@Input() integrations: Integration[];
protected IntegrationType = IntegrationType;
}

View File

@@ -4,7 +4,11 @@
<section class="tw-mb-9">
<p bitTypography="body1">{{ "integrationsDesc" | i18n }}</p>
<sm-integration-grid [integrations]="integrations"></sm-integration-grid>
<app-integration-grid
[integrations]="integrations"
[tooltipI18nKey]="'smIntegrationTooltip'"
[ariaI18nKey]="'smIntegrationCardAriaLabel'"
></app-integration-grid>
</section>
<section class="tw-mb-9">
@@ -12,5 +16,9 @@
{{ "sdks" | i18n }}
</h2>
<p bitTypography="body1">{{ "sdksDesc" | i18n }}</p>
<sm-integration-grid [integrations]="sdks"></sm-integration-grid>
<app-integration-grid
[integrations]="sdks"
[tooltipI18nKey]="'smSdkTooltip'"
[ariaI18nKey]="'smSdkAriaLabel'"
></app-integration-grid>
</section>

View File

@@ -4,14 +4,17 @@ import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SharedModule } from "@bitwarden/components/src/shared";
import {
IntegrationCardComponent,
IntegrationGridComponent,
} from "@bitwarden/web-vault/app/shared";
import { SYSTEM_THEME_OBSERVABLE } from "../../../../../../libs/angular/src/services/injection-tokens";
import { I18nService } from "../../../../../../libs/common/src/platform/abstractions/i18n.service";
import { ThemeType } from "../../../../../../libs/common/src/platform/enums";
import { ThemeStateService } from "../../../../../../libs/common/src/platform/theming/theme-state.service";
import { I18nPipe } from "../../../../../../libs/components/src/shared/i18n.pipe";
import { IntegrationCardComponent } from "./integration-card/integration-card.component";
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
import { IntegrationsComponent } from "./integrations.component";
@Component({
@@ -31,18 +34,12 @@ describe("IntegrationsComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [
IntegrationsComponent,
IntegrationGridComponent,
IntegrationCardComponent,
MockHeaderComponent,
MockNewMenuComponent,
I18nPipe,
],
declarations: [IntegrationsComponent, MockHeaderComponent, MockNewMenuComponent],
imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule],
providers: [
{
provide: I18nService,
useValue: mock<I18nService>({ t: (key) => key }),
useValue: mock<I18nService>(),
},
{
provide: ThemeStateService,

View File

@@ -1,9 +1,7 @@
import { Component } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Integration } from "./models/integration";
import { Integration } from "@bitwarden/web-vault/app/shared";
@Component({
selector: "sm-integrations",
@@ -12,11 +10,10 @@ import { Integration } from "./models/integration";
export class IntegrationsComponent {
private integrationsAndSdks: Integration[] = [];
constructor(i18nService: I18nService) {
constructor() {
this.integrationsAndSdks = [
{
name: "Rust",
linkText: i18nService.t("rustSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk",
image: "../../../../../../../images/secrets-manager/sdks/rust.svg",
imageDarkMode: "../../../../../../../images/secrets-manager/sdks/rust-white.svg",
@@ -24,7 +21,6 @@ export class IntegrationsComponent {
},
{
name: "GitHub Actions",
linkText: i18nService.t("setUpGithubActions"),
linkURL: "https://bitwarden.com/help/github-actions-integration/",
image: "../../../../../../../images/secrets-manager/integrations/github.svg",
imageDarkMode: "../../../../../../../images/secrets-manager/integrations/github-white.svg",
@@ -32,7 +28,6 @@ export class IntegrationsComponent {
},
{
name: "GitLab CI/CD",
linkText: i18nService.t("setUpGitlabCICD"),
linkURL: "https://bitwarden.com/help/gitlab-integration/",
image: "../../../../../../../images/secrets-manager/integrations/gitlab.svg",
imageDarkMode: "../../../../../../../images/secrets-manager/integrations/gitlab-white.svg",
@@ -40,35 +35,30 @@ export class IntegrationsComponent {
},
{
name: "Ansible",
linkText: i18nService.t("setUpAnsible"),
linkURL: "https://bitwarden.com/help/ansible-integration/",
image: "../../../../../../../images/secrets-manager/integrations/ansible.svg",
type: IntegrationType.Integration,
},
{
name: "C#",
linkText: i18nService.t("cSharpSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/csharp",
image: "../../../../../../../images/secrets-manager/sdks/c-sharp.svg",
type: IntegrationType.SDK,
},
{
name: "C++",
linkText: i18nService.t("cPlusPlusSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/cpp",
image: "../../../../../../../images/secrets-manager/sdks/c-plus-plus.png",
type: IntegrationType.SDK,
},
{
name: "Go",
linkText: i18nService.t("goSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/go",
image: "../../../../../../../images/secrets-manager/sdks/go.svg",
type: IntegrationType.SDK,
},
{
name: "Java",
linkText: i18nService.t("javaSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/java",
image: "../../../../../../../images/secrets-manager/sdks/java.svg",
imageDarkMode: "../../../../../../../images/secrets-manager/sdks/java-white.svg",
@@ -76,35 +66,30 @@ export class IntegrationsComponent {
},
{
name: "JS WebAssembly",
linkText: i18nService.t("jsWebAssemblySDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/js",
image: "../../../../../../../images/secrets-manager/sdks/wasm.svg",
type: IntegrationType.SDK,
},
{
name: "php",
linkText: i18nService.t("phpSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/php",
image: "../../../../../../../images/secrets-manager/sdks/php.svg",
type: IntegrationType.SDK,
},
{
name: "Python",
linkText: i18nService.t("pythonSDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/python",
image: "../../../../../../../images/secrets-manager/sdks/python.svg",
type: IntegrationType.SDK,
},
{
name: "Ruby",
linkText: i18nService.t("rubySDKRepo"),
linkURL: "https://github.com/bitwarden/sdk/tree/main/languages/ruby",
image: "../../../../../../../images/secrets-manager/sdks/ruby.png",
type: IntegrationType.SDK,
},
{
name: "Kubernetes Operator",
linkText: i18nService.t("setUpKubernetes"),
linkURL: "https://bitwarden.com/help/secrets-manager-kubernetes-operator/",
image: "../../../../../../../images/secrets-manager/integrations/kubernetes.svg",
type: IntegrationType.Integration,

View File

@@ -1,15 +1,22 @@
import { NgModule } from "@angular/core";
import {
IntegrationCardComponent,
IntegrationGridComponent,
} from "@bitwarden/web-vault/app/shared";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { IntegrationCardComponent } from "./integration-card/integration-card.component";
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
import { IntegrationsRoutingModule } from "./integrations-routing.module";
import { IntegrationsComponent } from "./integrations.component";
@NgModule({
imports: [SecretsManagerSharedModule, IntegrationsRoutingModule],
declarations: [IntegrationsComponent, IntegrationGridComponent, IntegrationCardComponent],
providers: [],
imports: [
SecretsManagerSharedModule,
IntegrationsRoutingModule,
IntegrationCardComponent,
IntegrationGridComponent,
],
declarations: [IntegrationsComponent],
})
export class IntegrationsModule {}

View File

@@ -1,21 +0,0 @@
import { IntegrationType } from "@bitwarden/common/enums";
/** Integration or SDK */
export type Integration = {
name: string;
image: string;
/**
* Optional image shown in dark mode.
*/
imageDarkMode?: string;
linkURL: string;
linkText: string;
type: IntegrationType;
/**
* Shows the "New" badge until the defined date.
* When omitted, the badge is never shown.
*
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
};

View File

@@ -46,6 +46,7 @@
"../../apps/web/src/**/*.spec.ts",
"../../libs/common/src/platform/services/**/*.worker.ts",
"src/**/*.stories.ts"
"src/**/*.stories.ts",
"src/**/*.spec.ts"
]
}