1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

SM-1146: Display total counts of projects, machine accounts, secrets in Secrets Manager (#9791)

* SM-1146: Secrets Manager total counts

* SM-1146: Tab link component simplifications

* SM-1146: Total counts update on CRUD

* SM-1146: Total counts API paths update

* SM-1146: Unit test coverage for services

* SM-1146: Fix incorrect types returned

* SM-1146: Storybook example for tab-link with child counter

* SM-1146: Tailwind states with groups

* SM-1146: Moving counts view types in one file

* SM-1146: Moving counts methods, responses to one shared service

* SM-1146: Removing redundant services imports

* SM-1146: Removing redundant observables

* SM-1337: Total counts hidden for suspended organizations

* SM-1336: Total counts updated on import

* SM-1336: Import error handling refactor

* SM-1336: Import error handling improvements

* SM-1336: Import error not working with project errors, Unit Test coverage

* Update bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* SM-1336: UT deprecation removal

* SM-1336: Better UT

* SM-1336: Lint fix

* SM-1146: Linter fix

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Maciej Zieniuk 2024-08-08 15:12:55 +02:00 committed by GitHub
parent dfb69f8130
commit a3bf74ae1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1143 additions and 104 deletions

View File

@ -6,19 +6,31 @@
[text]="'projects' | i18n"
route="projects"
[relativeTo]="route.parent"
></bit-nav-item>
>
<div slot="end" *ngIf="isOrgEnabled$ | async">
{{ organizationCounts?.projects }}
</div>
</bit-nav-item>
<bit-nav-item
icon="bwi-key"
[text]="'secrets' | i18n"
route="secrets"
[relativeTo]="route.parent"
></bit-nav-item>
>
<div slot="end" *ngIf="isOrgEnabled$ | async">
{{ organizationCounts?.secrets }}
</div>
</bit-nav-item>
<bit-nav-item
icon="bwi-wrench"
[text]="'machineAccounts' | i18n"
route="machine-accounts"
[relativeTo]="route.parent"
></bit-nav-item>
>
<div slot="end" *ngIf="isOrgEnabled$ | async">
{{ organizationCounts?.serviceAccounts }}
</div>
</bit-nav-item>
<bit-nav-item
icon="bwi-providers"
[text]="'integrations' | i18n"

View File

@ -1,26 +1,91 @@
import { Component } from "@angular/core";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { concatMap } from "rxjs";
import {
combineLatest,
concatMap,
distinctUntilChanged,
filter,
map,
Observable,
startWith,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { SecretsManagerLogo } from "@bitwarden/web-vault/app/layouts/secrets-manager-logo";
import { OrganizationCounts } from "../models/view/counts.view";
import { ProjectService } from "../projects/project.service";
import { SecretService } from "../secrets/secret.service";
import { ServiceAccountService } from "../service-accounts/service-account.service";
import { SecretsManagerPortingApiService } from "../settings/services/sm-porting-api.service";
import { CountService } from "../shared/counts/count.service";
@Component({
selector: "sm-navigation",
templateUrl: "./navigation.component.html",
})
export class NavigationComponent {
export class NavigationComponent implements OnInit, OnDestroy {
protected readonly logo = SecretsManagerLogo;
protected orgFilter = (org: Organization) => org.canAccessSecretsManager;
protected isAdmin$ = this.route.params.pipe(
concatMap(
async (params) => (await this.organizationService.get(params.organizationId))?.isAdmin,
),
);
protected isAdmin$: Observable<boolean>;
protected isOrgEnabled$: Observable<boolean>;
protected organizationCounts: OrganizationCounts;
private destroy$: Subject<void> = new Subject<void>();
constructor(
protected route: ActivatedRoute,
private organizationService: OrganizationService,
private countService: CountService,
private projectService: ProjectService,
private secretService: SecretService,
private serviceAccountService: ServiceAccountService,
private portingApiService: SecretsManagerPortingApiService,
) {}
ngOnInit() {
const org$ = this.route.params.pipe(
concatMap((params) => this.organizationService.get(params.organizationId)),
distinctUntilChanged(),
takeUntil(this.destroy$),
);
this.isAdmin$ = org$.pipe(
map((org) => org?.isAdmin),
takeUntil(this.destroy$),
);
this.isOrgEnabled$ = org$.pipe(
map((org) => org?.enabled),
takeUntil(this.destroy$),
);
combineLatest([
org$,
this.projectService.project$.pipe(startWith(null)),
this.secretService.secret$.pipe(startWith(null)),
this.serviceAccountService.serviceAccount$.pipe(startWith(null)),
this.portingApiService.imports$.pipe(startWith(null)),
])
.pipe(
filter(([org]) => org?.enabled),
switchMap(([org]) => this.countService.getOrganizationCounts(org.id)),
takeUntil(this.destroy$),
)
.subscribe((organizationCounts) => {
this.organizationCounts = {
projects: organizationCounts.projects,
secrets: organizationCounts.secrets,
serviceAccounts: organizationCounts.serviceAccounts,
};
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,17 @@
export type OrganizationCounts = {
projects: number;
secrets: number;
serviceAccounts: number;
};
export type ProjectCounts = {
secrets: number;
people: number;
serviceAccounts: number;
};
export type ServiceAccountCounts = {
projects: number;
people: number;
accessTokens: number;
};

View File

@ -54,7 +54,7 @@
[projects]="view.latestProjects"
></sm-projects-list>
<div *ngIf="view.allProjects.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestProjects.length : view.allProjects.length }}
{{ "showingPortionOfTotal" | i18n: view.latestProjects.length : view.counts.projects }}
<a bitLink routerLink="projects" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>
@ -72,7 +72,7 @@
[secrets]="view.latestSecrets"
></sm-secrets-list>
<div *ngIf="view.allSecrets.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestSecrets.length : view.allSecrets.length }}
{{ "showingPortionOfTotal" | i18n: view.latestSecrets.length : view.counts.secrets }}
<a bitLink routerLink="secrets" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>

View File

@ -21,6 +21,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { OrganizationCounts } from "../models/view/counts.view";
import { ProjectListView } from "../models/view/project-list.view";
import { SecretListView } from "../models/view/secret-list.view";
import {
@ -51,6 +52,7 @@ import {
ServiceAccountOperation,
} from "../service-accounts/dialog/service-account-dialog.component";
import { ServiceAccountService } from "../service-accounts/service-account.service";
import { CountService } from "../shared/counts/count.service";
import { SecretsListComponent } from "../shared/secrets-list.component";
import { SMOnboardingTasks, SMOnboardingTasksService } from "./sm-onboarding-tasks.service";
@ -87,11 +89,13 @@ export class OverviewComponent implements OnInit, OnDestroy {
latestProjects: ProjectListView[];
latestSecrets: SecretListView[];
tasks: OrganizationTasks;
counts: OrganizationCounts;
}>;
constructor(
private route: ActivatedRoute,
private projectService: ProjectService,
private countService: CountService,
private secretService: SecretService,
private serviceAccountService: ServiceAccountService,
private dialogService: DialogService,
@ -148,10 +152,19 @@ export class OverviewComponent implements OnInit, OnDestroy {
share(),
);
const counts$ = combineLatest([
orgId$,
this.secretService.secret$.pipe(startWith(null)),
this.projectService.project$.pipe(startWith(null)),
]).pipe(
switchMap(([orgId]) => this.countService.getOrganizationCounts(orgId)),
share(),
);
this.view$ = orgId$.pipe(
switchMap((orgId) =>
combineLatest([projects$, secrets$, serviceAccounts$]).pipe(
switchMap(async ([projects, secrets, serviceAccounts]) => ({
combineLatest([projects$, secrets$, serviceAccounts$, counts$]).pipe(
switchMap(async ([projects, secrets, serviceAccounts, counts]) => ({
latestProjects: this.getRecentItems(projects, this.tableSize),
latestSecrets: this.getRecentItems(secrets, this.tableSize),
allProjects: projects,
@ -162,6 +175,11 @@ export class OverviewComponent implements OnInit, OnDestroy {
createProject: projects.length > 0,
createServiceAccount: serviceAccounts.length > 0,
}),
counts: {
projects: counts.projects,
secrets: counts.secrets,
serviceAccounts: counts.serviceAccounts,
},
})),
),
),

View File

@ -3,10 +3,25 @@
<bit-breadcrumb [route]="['..']" icon="bwi-angle-left">{{ "projects" | i18n }}</bit-breadcrumb>
</bit-breadcrumbs>
<bit-tab-nav-bar label="Main" slot="tabs">
<bit-tab-link [route]="['secrets']">{{ "secrets" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['secrets']">
{{ "secrets" | i18n }}
<div slot="end" class="tw-text-muted">
{{ projectCounts?.secrets }}
</div>
</bit-tab-link>
<ng-container *ngIf="project.write">
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['machine-accounts']">{{ "machineAccounts" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['people']">
{{ "people" | i18n }}
<div slot="end" class="tw-text-muted">
{{ projectCounts?.people }}
</div>
</bit-tab-link>
<bit-tab-link [route]="['machine-accounts']">
{{ "machineAccounts" | i18n }}
<div slot="end" class="tw-text-muted">
{{ projectCounts?.serviceAccounts }}
</div>
</bit-tab-link>
</ng-container>
</bit-tab-nav-bar>
<sm-new-menu></sm-new-menu>

View File

@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
filter,
@ -13,11 +13,13 @@ import {
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { ProjectCounts } from "../../models/view/counts.view";
import { ProjectView } from "../../models/view/project.view";
import { SecretService } from "../../secrets/secret.service";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import { CountService } from "../../shared/counts/count.service";
import {
OperationType,
ProjectDialogComponent,
@ -31,6 +33,7 @@ import { ProjectService } from "../project.service";
})
export class ProjectComponent implements OnInit, OnDestroy {
protected project$: Observable<ProjectView>;
protected projectCounts: ProjectCounts;
private organizationId: string;
private projectId: string;
@ -40,11 +43,11 @@ export class ProjectComponent implements OnInit, OnDestroy {
constructor(
private route: ActivatedRoute,
private projectService: ProjectService,
private router: Router,
private secretService: SecretService,
private accessPolicyService: AccessPolicyService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private organizationService: OrganizationService,
private countService: CountService,
) {}
ngOnInit(): void {
@ -62,13 +65,23 @@ export class ProjectComponent implements OnInit, OnDestroy {
const organization$ = this.route.params.pipe(
concatMap((params) => this.organizationService.get$(params.organizationId)),
);
const projectCounts$ = combineLatest([
this.route.params,
this.secretService.secret$.pipe(startWith(null)),
this.accessPolicyService.accessPolicy$.pipe(startWith(null)),
]).pipe(switchMap(([params]) => this.countService.getProjectCounts(params.projectId)));
combineLatest([projectId$, organization$])
combineLatest([projectId$, organization$, projectCounts$])
.pipe(takeUntil(this.destroy$))
.subscribe(([projectId, organization]) => {
.subscribe(([projectId, organization, projectCounts]) => {
this.organizationId = organization.id;
this.projectId = projectId;
this.organizationEnabled = organization.enabled;
this.projectCounts = {
secrets: projectCounts.secrets,
people: projectCounts.people,
serviceAccounts: projectCounts.serviceAccounts,
};
});
}

View File

@ -8,6 +8,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { DialogService } from "@bitwarden/components";
import { ServiceAccountPeopleAccessPoliciesView } from "../../models/view/access-policies/service-account-people-access-policies.view";
import { AccessPolicySelectorService } from "../../shared/access-policies/access-policy-selector/access-policy-selector.service";
import {
ApItemValueType,
@ -179,7 +180,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
private async updateServiceAccountPeopleAccessPolicies(
serviceAccountId: string,
selectedPolicies: ApItemValueType[],
) {
): Promise<ServiceAccountPeopleAccessPoliciesView> {
const serviceAccountPeopleView = convertToPeopleAccessPoliciesView(selectedPolicies);
return await this.accessPolicyService.putServiceAccountPeopleAccessPolicies(
serviceAccountId,

View File

@ -10,9 +10,24 @@
</bit-breadcrumbs>
<sm-new-menu></sm-new-menu>
<bit-tab-nav-bar label="Main" slot="tabs">
<bit-tab-link [route]="['projects']">{{ "projects" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['people']">{{ "people" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['access']">{{ "accessTokens" | i18n }}</bit-tab-link>
<bit-tab-link [route]="['projects']">
{{ "projects" | i18n }}
<div slot="end" class="tw-text-muted">
{{ serviceAccountCounts?.projects }}
</div>
</bit-tab-link>
<bit-tab-link [route]="['people']">
{{ "people" | i18n }}
<div slot="end" class="tw-text-muted">
{{ serviceAccountCounts?.people }}
</div>
</bit-tab-link>
<bit-tab-link [route]="['access']">
{{ "accessTokens" | i18n }}
<div slot="end" class="tw-text-muted">
{{ serviceAccountCounts?.accessTokens }}
</div>
</bit-tab-link>
<bit-tab-link [route]="['events']">{{ "eventLogs" | i18n }}</bit-tab-link>
</bit-tab-nav-bar>
<button

View File

@ -1,13 +1,15 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute } from "@angular/router";
import { Subject, combineLatest, filter, startWith, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
import { ServiceAccountCounts } from "../models/view/counts.view";
import { ServiceAccountView } from "../models/view/service-account.view";
import { AccessPolicyService } from "../shared/access-policies/access-policy.service";
import { CountService } from "../shared/counts/count.service";
import { AccessService } from "./access/access.service";
import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component";
import { ServiceAccountService } from "./service-account.service";
@ -17,7 +19,6 @@ import { ServiceAccountService } from "./service-account.service";
})
export class ServiceAccountComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private organizationId: string;
private serviceAccountId: string;
private onChange$ = this.serviceAccountService.serviceAccount$.pipe(
@ -34,19 +35,38 @@ export class ServiceAccountComponent implements OnInit, OnDestroy {
),
),
);
protected serviceAccountCounts: ServiceAccountCounts;
constructor(
private route: ActivatedRoute,
private serviceAccountService: ServiceAccountService,
private accessPolicyService: AccessPolicyService,
private accessService: AccessService,
private dialogService: DialogService,
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private countService: CountService,
) {}
ngOnInit(): void {
this.serviceAccount$.pipe(takeUntil(this.destroy$)).subscribe((serviceAccountView) => {
const serviceAccountCounts$ = combineLatest([
this.route.params,
this.accessPolicyService.accessPolicy$.pipe(startWith(null)),
this.accessService.accessToken$.pipe(startWith(null)),
this.onChange$,
]).pipe(
switchMap(([params, _]) =>
this.countService.getServiceAccountCounts(params.serviceAccountId),
),
);
combineLatest([this.serviceAccount$, serviceAccountCounts$])
.pipe(takeUntil(this.destroy$))
.subscribe(([serviceAccountView, serviceAccountCounts]) => {
this.serviceAccountView = serviceAccountView;
this.serviceAccountCounts = {
projects: serviceAccountCounts.projects,
people: serviceAccountCounts.people,
accessTokens: serviceAccountCounts.accessTokens,
};
});
}

View File

@ -1,6 +1,6 @@
export class SecretsManagerImportErrorLine {
id: number;
type: "Project" | "Secret";
key: "string";
key: string;
errorMessage: string;
}

View File

@ -3,13 +3,11 @@ import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components";
import { DialogService, ToastService } from "@bitwarden/components";
import {
SecretsManagerImportErrorDialogComponent,
@ -33,8 +31,7 @@ export class SecretsManagerImportComponent implements OnInit, OnDestroy {
constructor(
private route: ActivatedRoute,
private i18nService: I18nService,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
protected fileDownloadService: FileDownloadService,
private logService: LogService,
private secretsManagerPortingApiService: SecretsManagerPortingApiService,
@ -60,46 +57,43 @@ export class SecretsManagerImportComponent implements OnInit, OnDestroy {
);
if (importContents == null) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("selectFile"),
);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("selectFile"),
});
return;
}
try {
const error = await this.secretsManagerPortingApiService.import(this.orgId, importContents);
await this.secretsManagerPortingApiService.import(this.orgId, importContents);
if (error?.lines?.length > 0) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("importSuccess"),
});
this.clearForm();
} catch (error: unknown) {
if (error instanceof SecretsManagerImportError && error?.lines?.length > 0) {
this.openImportErrorDialog(error);
return;
} else if (!Utils.isNullOrWhitespace(error?.message)) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
error.message,
);
return;
} else if (error != null) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("errorReadingImportFile"),
);
return;
} else {
let message;
if (error instanceof Error && !Utils.isNullOrWhitespace(error?.message)) {
message = error.message;
} else {
message = this.i18nService.t("errorReadingImportFile");
}
this.platformUtilsService.showToast("success", null, this.i18nService.t("importSuccess"));
this.clearForm();
} catch (error) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("errorReadingImportFile"),
);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message,
});
this.logService.error(error);
}
}
};
protected async getImportContents(

View File

@ -0,0 +1,286 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrgKey } from "@bitwarden/common/types/key";
import { SecretsManagerImportError } from "../models/error/sm-import-error";
import { SecretsManagerImportRequest } from "../models/requests/sm-import.request";
import { SecretsManagerImportedProjectRequest } from "../models/requests/sm-imported-project.request";
import { SecretsManagerImportedSecretRequest } from "../models/requests/sm-imported-secret.request";
import { SecretsManagerPortingApiService } from "./sm-porting-api.service";
describe("SecretsManagerPortingApiService", () => {
let sut: SecretsManagerPortingApiService;
const apiService = mock<ApiService>();
const encryptService = mock<EncryptService>();
const cryptoService = mock<CryptoService>();
beforeEach(() => {
jest.resetAllMocks();
sut = new SecretsManagerPortingApiService(apiService, encryptService, cryptoService);
encryptService.encrypt.mockResolvedValue(mockEncryptedString);
encryptService.decryptToUtf8.mockResolvedValue(mockUnencryptedString);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
cryptoService.getOrgKey.mockResolvedValue(mockOrgKey);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
describe("import", () => {
const organizationId = Utils.newGuid();
const project1 = createProject();
const project2 = createProject();
const secret1 = createSecret(project1.id);
const secret2 = createSecret(project1.id);
const importData = createImportData([project1, project2], [secret1, secret2]);
it("emits the import successful", async () => {
const expectedRequest = toRequest([project1, project2], [secret1, secret2]);
let subscriptionCount = 0;
sut.imports$.subscribe((request) => {
expect(request).toBeDefined();
expect(request.projects.length).toEqual(2);
expect(request.secrets.length).toEqual(2);
expect(request.projects[0]).toEqual(expectedRequest.projects[0]);
expect(request.projects[1]).toEqual(expectedRequest.projects[1]);
expect(request.secrets[0]).toEqual(expectedRequest.secrets[0]);
expect(request.secrets[1]).toEqual(expectedRequest.secrets[1]);
subscriptionCount++;
});
await sut.import(organizationId, importData);
expect(subscriptionCount).toEqual(1);
});
it("correct api service send parameters", async () => {
const expectedRequest = toRequest([project1, project2], [secret1, secret2]);
await sut.import(organizationId, importData);
expect(apiService.send).toHaveBeenCalledWith(
"POST",
`/sm/${organizationId}/import`,
expectedRequest,
true,
true,
);
});
describe("throws SecretsManagerImportError", () => {
it("server error", async () => {
apiService.send.mockRejectedValue(new Error("server error"));
await expect(async () => {
await sut.import(organizationId, importData);
}).rejects.toThrow(new SecretsManagerImportError("server error"));
});
it("validation error project invalid field in list", async () => {
apiService.send.mockRejectedValue({
message: "invalid field",
validationErrors: {
"$.Projects[1].id": ["invalid id"],
},
} as ValidationError);
const expectedError = new SecretsManagerImportError();
expectedError.lines = [
{
id: 2,
type: "Project",
key: project2.name,
errorMessage: "invalid id",
},
];
try {
await sut.import(organizationId, importData);
// Expected to throw error before this line
expect(true).toBe(false);
} catch (err: unknown) {
expect(err).toBeInstanceOf(SecretsManagerImportError);
const importError = err as SecretsManagerImportError;
expect(importError.lines).toEqual(expectedError.lines);
}
});
it("validation error project invalid field in field", async () => {
apiService.send.mockRejectedValue({
message: "invalid field",
validationErrors: {
"Projects[1].id": ["invalid id"],
},
} as ValidationError);
const expectedError = new SecretsManagerImportError();
expectedError.lines = [
{
id: 2,
type: "Project",
key: project2.name,
errorMessage: "invalid id",
},
];
try {
await sut.import(organizationId, importData);
// Expected to throw error before this line
expect(true).toBe(false);
} catch (err: unknown) {
expect(err).toBeInstanceOf(SecretsManagerImportError);
const importError = err as SecretsManagerImportError;
expect(importError.lines).toEqual(expectedError.lines);
}
});
it("validation error secret invalid field in list", async () => {
apiService.send.mockRejectedValue({
message: "invalid field",
validationErrors: {
"$.Secrets[1].id": ["invalid id"],
},
} as ValidationError);
const expectedError = new SecretsManagerImportError();
expectedError.lines = [
{
id: 2,
type: "Secret",
key: secret2.key,
errorMessage: "invalid id",
},
];
try {
await sut.import(organizationId, importData);
// Expected to throw error before this line
expect(true).toBe(false);
} catch (err: unknown) {
expect(err).toBeInstanceOf(SecretsManagerImportError);
const importError = err as SecretsManagerImportError;
expect(importError.lines).toEqual(expectedError.lines);
}
});
it("validation error secret invalid field in field", async () => {
apiService.send.mockRejectedValue({
message: "invalid field",
validationErrors: {
"Secrets[1].id": ["invalid id"],
},
} as ValidationError);
const expectedError = new SecretsManagerImportError();
expectedError.lines = [
{
id: 2,
type: "Secret",
key: secret2.key,
errorMessage: "invalid id",
},
];
try {
await sut.import(organizationId, importData);
// Expected to throw error before this line
expect(true).toBe(false);
} catch (err: unknown) {
expect(err).toBeInstanceOf(SecretsManagerImportError);
const importError = err as SecretsManagerImportError;
expect(importError.lines).toEqual(expectedError.lines);
}
});
});
});
});
type ValidationError = {
message: string;
validationErrors: Record<string, string[]>;
};
type ImportProject = {
id: string;
name: string;
};
type ImportSecret = {
id: string;
key: string;
value: string;
note: string;
projectIds: string[];
};
function createProject(): ImportProject {
const id = Utils.newGuid();
return {
id: id,
name: "project " + id,
};
}
function createSecret(projectId: string): ImportSecret {
const id = Utils.newGuid();
return {
id: id,
key: "key " + id,
value: "value " + id,
note: "note " + id,
projectIds: [projectId],
};
}
function createImportData(projects: ImportProject[], secrets: ImportSecret[]): string {
return JSON.stringify({
projects: projects,
secrets: secrets,
});
}
function toRequest(
projects: ImportProject[],
secrets: ImportSecret[],
): SecretsManagerImportRequest {
return {
projects: projects.map(
(project) =>
({
id: project.id,
name: mockEncryptedString,
}) as SecretsManagerImportedProjectRequest,
),
secrets: secrets.map(
(secret) =>
({
id: secret.id,
key: mockEncryptedString,
value: mockEncryptedString,
note: mockEncryptedString,
projectIds: secret.projectIds,
}) as SecretsManagerImportedSecretRequest,
),
} as SecretsManagerImportRequest;
}
const mockEncryptedString = {
encryptedString: "mockEncryptedString",
} as EncString;
const mockUnencryptedString = "mockUnEncryptedString";

View File

@ -1,10 +1,10 @@
import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SecretsManagerImportError } from "../models/error/sm-import-error";
@ -22,11 +22,13 @@ import {
providedIn: "root",
})
export class SecretsManagerPortingApiService {
protected _imports = new Subject<SecretsManagerImportRequest>();
imports$ = this._imports.asObservable();
constructor(
private apiService: ApiService,
private encryptService: EncryptService,
private cryptoService: CryptoService,
private i18nService: I18nService,
) {}
async export(organizationId: string): Promise<string> {
@ -45,7 +47,7 @@ export class SecretsManagerPortingApiService {
);
}
async import(organizationId: string, fileContents: string): Promise<SecretsManagerImportError> {
async import(organizationId: string, fileContents: string): Promise<void> {
let requestObject = {};
try {
@ -59,9 +61,11 @@ export class SecretsManagerPortingApiService {
true,
true,
);
this._imports.next(requestBody);
} catch (error) {
const errorResponse = new ErrorResponse(error, 400);
return this.handleServerError(errorResponse, requestObject);
throw this.handleServerError(errorResponse, requestObject);
}
}
@ -162,12 +166,12 @@ export class SecretsManagerPortingApiService {
let itemType;
const id = Number(key.match(/[0-9]+/)[0]);
switch (key.match(/^\w+/)[0]) {
case "Projects":
switch (key.match(/^[$\\.]*(\w+)/)[1].toLowerCase()) {
case "projects":
item = importResult.projects[id];
itemType = "Project";
break;
case "Secrets":
case "secrets":
item = importResult.secrets[id];
itemType = "Secret";
break;
@ -177,8 +181,8 @@ export class SecretsManagerPortingApiService {
result.lines.push({
id: id + 1,
type: itemType == "Project" ? "Project" : "Secret",
key: item.key,
type: itemType === "Project" ? "Project" : "Secret",
key: itemType === "Project" ? item.name : item.key,
errorMessage: value.length > 0 ? value[0] : "",
});
});

View File

@ -5,7 +5,6 @@ import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { SecretsManagerImportErrorDialogComponent } from "./dialog/sm-import-error-dialog.component";
import { SecretsManagerExportComponent } from "./porting/sm-export.component";
import { SecretsManagerImportComponent } from "./porting/sm-import.component";
import { SecretsManagerPortingApiService } from "./services/sm-porting-api.service";
import { SecretsManagerPortingService } from "./services/sm-porting.service";
import { SettingsRoutingModule } from "./settings-routing.module";
@ -16,6 +15,6 @@ import { SettingsRoutingModule } from "./settings-routing.module";
SecretsManagerExportComponent,
SecretsManagerImportErrorDialogComponent,
],
providers: [SecretsManagerPortingService, SecretsManagerPortingApiService],
providers: [SecretsManagerPortingService],
})
export class SettingsModule {}

View File

@ -0,0 +1,359 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrgKey } from "@bitwarden/common/types/key";
import {
GroupAccessPolicyView,
ServiceAccountAccessPolicyView,
UserAccessPolicyView,
} from "../../models/view/access-policies/access-policy.view";
import { ProjectPeopleAccessPoliciesView } from "../../models/view/access-policies/project-people-access-policies.view";
import { ProjectServiceAccountsAccessPoliciesView } from "../../models/view/access-policies/project-service-accounts-access-policies.view";
import {
GrantedProjectPolicyPermissionDetailsView,
ServiceAccountGrantedPoliciesView,
} from "../../models/view/access-policies/service-account-granted-policies.view";
import { ServiceAccountPeopleAccessPoliciesView } from "../../models/view/access-policies/service-account-people-access-policies.view";
import { AccessPolicyService } from "./access-policy.service";
import { PeopleAccessPoliciesRequest } from "./models/requests/people-access-policies.request";
import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request";
import { ServiceAccountGrantedPoliciesRequest } from "./models/requests/service-account-granted-policies.request";
import { trackEmissions } from "@bitwarden/common/../spec";
describe("AccessPolicyService", () => {
let sut: AccessPolicyService;
const cryptoService = mock<CryptoService>();
const apiService = mock<ApiService>();
const encryptService = mock<EncryptService>();
beforeEach(() => {
jest.resetAllMocks();
sut = new AccessPolicyService(cryptoService, apiService, encryptService);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
describe("putProjectPeopleAccessPolicies", () => {
it("emits the updated policies", async () => {
const userAccessPolicyView1 = createUserAccessPolicyView(false, true);
const userAccessPolicyView2 = createUserAccessPolicyView(true, false);
const groupAccessPolicyView1 = createGroupAccessPolicyView(false, true);
const groupAccessPolicyView2 = createGroupAccessPolicyView(true, false);
const view = {
userAccessPolicies: [userAccessPolicyView1, userAccessPolicyView2],
groupAccessPolicies: [groupAccessPolicyView1, groupAccessPolicyView2],
} as ProjectPeopleAccessPoliciesView;
apiService.send.mockResolvedValue(toProjectPeopleAccessPoliciesResponseRaw(view));
const emissions = trackEmissions(sut.accessPolicy$);
const expectedRequest = toPeopleAccessPoliciesRequest(view);
const projectId = Utils.newGuid();
const result = await sut.putProjectPeopleAccessPolicies(projectId, view);
expect(result).toEqual(view);
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/projects/" + projectId + "/access-policies/people",
expectedRequest,
true,
true,
);
expect(emissions).toEqual([view]);
});
});
describe("putServiceAccountPeopleAccessPolicies", () => {
it("emits the updated policies", async () => {
const userAccessPolicyView1 = createUserAccessPolicyView(false, true);
const userAccessPolicyView2 = createUserAccessPolicyView(true, false);
const groupAccessPolicyView1 = createGroupAccessPolicyView(false, true);
const groupAccessPolicyView2 = createGroupAccessPolicyView(true, false);
const view = {
userAccessPolicies: [userAccessPolicyView1, userAccessPolicyView2],
groupAccessPolicies: [groupAccessPolicyView1, groupAccessPolicyView2],
} as ServiceAccountPeopleAccessPoliciesView;
apiService.send.mockResolvedValue(toServiceAccountPeopleAccessPoliciesResponseRaw(view));
const emissions = trackEmissions(sut.accessPolicy$);
const expectedRequest = toPeopleAccessPoliciesRequest(view);
const projectId = Utils.newGuid();
const result = await sut.putServiceAccountPeopleAccessPolicies(projectId, view);
expect(result).toEqual(view);
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/service-accounts/" + projectId + "/access-policies/people",
expectedRequest,
true,
true,
);
expect(emissions).toEqual([view]);
});
});
describe("putServiceAccountGrantedPolicies", () => {
it("emits the updated policies", async () => {
const policyPermissionDetailsView1 = createGrantedProjectPolicyPermissionDetailsView(
false,
false,
);
const policyPermissionDetailsView2 = createGrantedProjectPolicyPermissionDetailsView(
false,
true,
);
const policyPermissionDetailsView3 = createGrantedProjectPolicyPermissionDetailsView(
true,
true,
);
const view = {
grantedProjectPolicies: [
policyPermissionDetailsView1,
policyPermissionDetailsView2,
policyPermissionDetailsView3,
],
} as ServiceAccountGrantedPoliciesView;
apiService.send.mockResolvedValue(toServiceAccountGrantedPoliciesResponseRaw(view));
const emissions = trackEmissions(sut.accessPolicy$);
const expectedRequest = toServiceAccountGrantedPoliciesRequest(view);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
cryptoService.getOrgKey.mockResolvedValue(mockOrgKey);
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
const organizationId = Utils.newGuid();
const serviceAccountId = Utils.newGuid();
const result = await sut.putServiceAccountGrantedPolicies(
organizationId,
serviceAccountId,
view,
);
expect(result).toEqual(view);
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/service-accounts/" + serviceAccountId + "/granted-policies",
expectedRequest,
true,
true,
);
expect(emissions).toEqual([view]);
});
});
describe("putProjectServiceAccountsAccessPolicies", () => {
it("emits the updated policies", async () => {
const accessPolicyView1 = createServiceAccountAccessPolicyView(false);
const accessPolicyView2 = createServiceAccountAccessPolicyView(true);
const view = {
serviceAccountAccessPolicies: [accessPolicyView1, accessPolicyView2],
} as ProjectServiceAccountsAccessPoliciesView;
apiService.send.mockResolvedValue(toProjectServiceAccountsAccessPoliciesResponseRaw(view));
const emissions = trackEmissions(sut.accessPolicy$);
const expectedRequest = toProjectServiceAccountsAccessPoliciesRequest(view);
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey;
cryptoService.getOrgKey.mockResolvedValue(mockOrgKey);
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
const organizationId = Utils.newGuid();
const projectId = Utils.newGuid();
const result = await sut.putProjectServiceAccountsAccessPolicies(
organizationId,
projectId,
view,
);
expect(result).toEqual(view);
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/projects/" + projectId + "/access-policies/service-accounts",
expectedRequest,
true,
true,
);
expect(emissions).toEqual([view]);
});
});
});
function createUserAccessPolicyView(isWrite: boolean, currentUser: boolean): UserAccessPolicyView {
const id = Utils.newGuid();
return {
organizationUserId: id,
organizationUserName: "Example organization user name " + id,
read: true,
write: isWrite,
currentUser: currentUser,
};
}
function createGroupAccessPolicyView(
isWrite: boolean,
currentUserInGroup: boolean,
): GroupAccessPolicyView {
const id = Utils.newGuid();
return {
groupId: id,
groupName: "Example group name " + id,
currentUserInGroup: currentUserInGroup,
read: true,
write: isWrite,
};
}
function createServiceAccountAccessPolicyView(isWrite: boolean): ServiceAccountAccessPolicyView {
const id = Utils.newGuid();
return {
serviceAccountId: id,
serviceAccountName: "Example service account name " + id,
read: true,
write: isWrite,
};
}
function createGrantedProjectPolicyPermissionDetailsView(
isWrite: boolean,
hasPermissions: boolean,
): GrantedProjectPolicyPermissionDetailsView {
const id = Utils.newGuid();
return {
accessPolicy: {
grantedProjectId: id,
grantedProjectName: "Example project name " + id,
read: true,
write: isWrite,
},
hasPermission: hasPermissions,
};
}
function toPeopleAccessPoliciesRequest(
view: ProjectPeopleAccessPoliciesView | ServiceAccountPeopleAccessPoliciesView,
): PeopleAccessPoliciesRequest {
return {
userAccessPolicyRequests: view.userAccessPolicies.map((ap) => ({
granteeId: ap.organizationUserId,
read: ap.read,
write: ap.write,
})),
groupAccessPolicyRequests: view.groupAccessPolicies.map((ap) => ({
granteeId: ap.groupId,
read: ap.read,
write: ap.write,
})),
};
}
function toServiceAccountGrantedPoliciesRequest(
view: ServiceAccountGrantedPoliciesView,
): ServiceAccountGrantedPoliciesRequest {
return {
projectGrantedPolicyRequests: view.grantedProjectPolicies.map((ap) => ({
grantedId: ap.accessPolicy.grantedProjectId,
read: ap.accessPolicy.read,
write: ap.accessPolicy.write,
})),
};
}
function toProjectServiceAccountsAccessPoliciesRequest(
view: ProjectServiceAccountsAccessPoliciesView,
): ProjectServiceAccountsAccessPoliciesRequest {
return {
serviceAccountAccessPolicyRequests: view.serviceAccountAccessPolicies.map((ap) => {
return {
granteeId: ap.serviceAccountId,
read: ap.read,
write: ap.write,
};
}),
};
}
function toProjectPeopleAccessPoliciesResponseRaw(view: ProjectPeopleAccessPoliciesView) {
return {
userAccessPolicies: view.userAccessPolicies.map((ap) => ({
organizationUserId: ap.organizationUserId,
organizationUserName: ap.organizationUserName,
currentUser: ap.currentUser,
read: ap.read,
write: ap.write,
})),
groupAccessPolicies: view.groupAccessPolicies.map((ap) => ({
groupId: ap.groupId,
groupName: ap.groupName,
currentUserInGroup: ap.currentUserInGroup,
read: ap.read,
write: ap.write,
})),
};
}
function toServiceAccountPeopleAccessPoliciesResponseRaw(
view: ServiceAccountPeopleAccessPoliciesView,
) {
return {
userAccessPolicies: view.userAccessPolicies.map((ap) => ({
organizationUserId: ap.organizationUserId,
organizationUserName: ap.organizationUserName,
currentUser: ap.currentUser,
read: ap.read,
write: ap.write,
})),
groupAccessPolicies: view.groupAccessPolicies.map((ap) => ({
groupId: ap.groupId,
groupName: ap.groupName,
currentUserInGroup: ap.currentUserInGroup,
read: ap.read,
write: ap.write,
})),
};
}
function toServiceAccountGrantedPoliciesResponseRaw(view: ServiceAccountGrantedPoliciesView) {
return {
grantedProjectPolicies: view.grantedProjectPolicies.map((ap) => ({
accessPolicy: {
grantedProjectId: ap.accessPolicy.grantedProjectId,
grantedProjectName: ap.accessPolicy.grantedProjectName,
read: ap.accessPolicy.read,
write: ap.accessPolicy.write,
},
hasPermission: ap.hasPermission,
})),
};
}
function toProjectServiceAccountsAccessPoliciesResponseRaw(
view: ProjectServiceAccountsAccessPoliciesView,
) {
return {
serviceAccountAccessPolicies: view.serviceAccountAccessPolicies.map((ap) => ({
serviceAccountId: ap.serviceAccountId,
serviceAccountName: ap.serviceAccountName,
currentUser: true,
read: ap.read,
write: ap.write,
})),
};
}

View File

@ -1,4 +1,5 @@
import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
@ -46,6 +47,15 @@ import { GrantedProjectAccessPolicyPermissionDetailsResponse } from "./models/re
providedIn: "root",
})
export class AccessPolicyService {
protected _accessPolicy: Subject<
| ProjectPeopleAccessPoliciesView
| ProjectServiceAccountsAccessPoliciesView
| ServiceAccountPeopleAccessPoliciesView
| ServiceAccountGrantedPoliciesView
> = new Subject();
accessPolicy$ = this._accessPolicy.asObservable();
constructor(
private cryptoService: CryptoService,
protected apiService: ApiService,
@ -70,7 +80,7 @@ export class AccessPolicyService {
async putProjectPeopleAccessPolicies(
projectId: string,
peoplePoliciesView: ProjectPeopleAccessPoliciesView,
) {
): Promise<ProjectPeopleAccessPoliciesView> {
const request = this.getPeopleAccessPoliciesRequest(peoplePoliciesView);
const r = await this.apiService.send(
"PUT",
@ -80,7 +90,9 @@ export class AccessPolicyService {
true,
);
const results = new ProjectPeopleAccessPoliciesResponse(r);
return this.createPeopleAccessPoliciesView(results);
const view = this.createPeopleAccessPoliciesView(results);
this._accessPolicy.next(view);
return view;
}
async getServiceAccountPeopleAccessPolicies(
@ -101,7 +113,7 @@ export class AccessPolicyService {
async putServiceAccountPeopleAccessPolicies(
serviceAccountId: string,
peoplePoliciesView: ServiceAccountPeopleAccessPoliciesView,
) {
): Promise<ServiceAccountPeopleAccessPoliciesView> {
const request = this.getPeopleAccessPoliciesRequest(peoplePoliciesView);
const r = await this.apiService.send(
"PUT",
@ -111,7 +123,9 @@ export class AccessPolicyService {
true,
);
const results = new ServiceAccountPeopleAccessPoliciesResponse(r);
return this.createPeopleAccessPoliciesView(results);
const view = this.createPeopleAccessPoliciesView(results);
this._accessPolicy.next(view);
return view;
}
async getServiceAccountGrantedPolicies(
@ -145,7 +159,9 @@ export class AccessPolicyService {
);
const result = new ServiceAccountGrantedPoliciesPermissionDetailsResponse(r);
return await this.createServiceAccountGrantedPoliciesView(result, organizationId);
const view = await this.createServiceAccountGrantedPoliciesView(result, organizationId);
this._accessPolicy.next(view);
return view;
}
async getProjectServiceAccountsAccessPolicies(
@ -179,7 +195,9 @@ export class AccessPolicyService {
);
const result = new ProjectServiceAccountsAccessPoliciesResponse(r);
return await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
const view = await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
this._accessPolicy.next(view);
return view;
}
async getSecretAccessPolicies(
@ -450,7 +468,7 @@ export class AccessPolicyService {
private createPeopleAccessPoliciesView(
response: ProjectPeopleAccessPoliciesResponse | ServiceAccountPeopleAccessPoliciesResponse,
) {
): ProjectPeopleAccessPoliciesView | ServiceAccountPeopleAccessPoliciesView {
return {
userAccessPolicies: this.createUserAccessPolicyViews(response.userAccessPolicies),
groupAccessPolicies: this.createGroupAccessPolicyViews(response.groupAccessPolicies),

View File

@ -0,0 +1,98 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CountService } from "./count.service";
describe("SecretsManagerService", () => {
let sut: CountService;
const apiService = mock<ApiService>();
beforeEach(() => {
jest.resetAllMocks();
sut = new CountService(apiService);
});
it("instantiates", () => {
expect(sut).not.toBeFalsy();
});
describe("getOrganizationCounts", () => {
it("returns counts", async () => {
apiService.send.mockResolvedValue({
projects: 1,
secrets: 2,
serviceAccounts: 3,
});
const organizationId = Utils.newGuid();
const result = await sut.getOrganizationCounts(organizationId);
expect(result).not.toBeNull();
expect(result.projects).toEqual(1);
expect(result.secrets).toEqual(2);
expect(result.serviceAccounts).toEqual(3);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/organizations/" + organizationId + "/sm-counts",
null,
true,
true,
);
});
});
describe("getProjectCounts", () => {
it("returns counts", async () => {
apiService.send.mockResolvedValue({
people: 1,
secrets: 2,
serviceAccounts: 3,
});
const projectId = Utils.newGuid();
const result = await sut.getProjectCounts(projectId);
expect(result).not.toBeNull();
expect(result.people).toEqual(1);
expect(result.secrets).toEqual(2);
expect(result.serviceAccounts).toEqual(3);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/projects/" + projectId + "/sm-counts",
null,
true,
true,
);
});
});
describe("getServiceAccountCounts", () => {
it("returns counts", async () => {
const serviceAccountId = Utils.newGuid();
apiService.send.mockResolvedValue({
projects: 1,
people: 2,
accessTokens: 3,
});
const result = await sut.getServiceAccountCounts(serviceAccountId);
expect(result).not.toBeNull();
expect(result.projects).toEqual(1);
expect(result.people).toEqual(2);
expect(result.accessTokens).toEqual(3);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/service-accounts/" + serviceAccountId + "/sm-counts",
null,
true,
true,
);
});
});
});

View File

@ -0,0 +1,47 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationCountsResponse } from "./models/responses/organization-counts.response";
import { ProjectCountsResponse } from "./models/responses/project-counts.response";
import { ServiceAccountCountsResponse } from "./models/responses/service-account-counts.response";
@Injectable({
providedIn: "root",
})
export class CountService {
constructor(private apiService: ApiService) {}
async getOrganizationCounts(organizationId: string): Promise<OrganizationCountsResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/sm-counts",
null,
true,
true,
);
return new OrganizationCountsResponse(r);
}
async getProjectCounts(projectId: string): Promise<ProjectCountsResponse> {
const r = await this.apiService.send(
"GET",
"/projects/" + projectId + "/sm-counts",
null,
true,
true,
);
return new ProjectCountsResponse(r);
}
async getServiceAccountCounts(serviceAccountId: string): Promise<ServiceAccountCountsResponse> {
const r = await this.apiService.send(
"GET",
"/service-accounts/" + serviceAccountId + "/sm-counts",
null,
true,
true,
);
return new ServiceAccountCountsResponse(r);
}
}

View File

@ -0,0 +1,14 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class OrganizationCountsResponse extends BaseResponse {
projects: number;
secrets: number;
serviceAccounts: number;
constructor(response: any) {
super(response);
this.projects = this.getResponseProperty("Projects");
this.secrets = this.getResponseProperty("Secrets");
this.serviceAccounts = this.getResponseProperty("ServiceAccounts");
}
}

View File

@ -0,0 +1,14 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class ProjectCountsResponse extends BaseResponse {
people: number;
secrets: number;
serviceAccounts: number;
constructor(response: any) {
super(response);
this.people = this.getResponseProperty("People");
this.secrets = this.getResponseProperty("Secrets");
this.serviceAccounts = this.getResponseProperty("ServiceAccounts");
}
}

View File

@ -0,0 +1,14 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class ServiceAccountCountsResponse extends BaseResponse {
projects: number;
people: number;
accessTokens: number;
constructor(response: any) {
super(response);
this.projects = this.getResponseProperty("Projects");
this.people = this.getResponseProperty("People");
this.accessTokens = this.getResponseProperty("AccessTokens");
}
}

View File

@ -100,13 +100,9 @@
</ng-template>
<div
#endSlot
*ngIf="data.open"
class="tw-flex -tw-ml-3 tw-pr-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
[ngClass]="[
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
endSlot.childElementCount === 0 ? 'tw-hidden' : '',
]"
class="tw-flex -tw-ml-3 tw-pr-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
>
<ng-content select="[slot=end]"></ng-content>
</div>

View File

@ -9,6 +9,12 @@
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"
role="link"
class="tw-flex tw-group/tab hover:tw-no-underline"
>
<div class="group-hover/tab:tw-underline">
<ng-content></ng-content>
</div>
<div class="tw-font-normal tw-ml-2 empty:tw-ml-0">
<ng-content select="[slot=end]"></ng-content>
</div>
</a>

View File

@ -33,6 +33,12 @@ class ItemThreeDummyComponent {}
})
class DisabledDummyComponent {}
@Component({
selector: "bit-tab-item-with-child-counter-dummy",
template: "Router - Item With Child Counter selected",
})
class ItemWithChildCounterDummyComponent {}
export default {
title: "Component Library/Tabs",
component: TabGroupComponent,
@ -42,6 +48,7 @@ export default {
ActiveDummyComponent,
ItemTwoDummyComponent,
ItemThreeDummyComponent,
ItemWithChildCounterDummyComponent,
DisabledDummyComponent,
],
imports: [CommonModule, TabsModule, ButtonModule, FormFieldModule, RouterModule],
@ -55,6 +62,7 @@ export default {
{ path: "active", component: ActiveDummyComponent },
{ path: "item-2", component: ItemTwoDummyComponent },
{ path: "item-3", component: ItemThreeDummyComponent },
{ path: "item-with-child-counter", component: ItemWithChildCounterDummyComponent },
{ path: "disabled", component: DisabledDummyComponent },
],
{ useHash: true },
@ -102,6 +110,12 @@ export const NavigationTabs: Story = {
<bit-tab-link [route]="['active']">Active</bit-tab-link>
<bit-tab-link [route]="['item-2']">Item 2</bit-tab-link>
<bit-tab-link [route]="['item-3']">Item 3</bit-tab-link>
<bit-tab-link [route]="['item-with-child-counter']">
Item With Counter
<div slot="end" class="tw-pl-2 tw-text-muted">
42
</div>
</bit-tab-link>
<bit-tab-link [route]="['disable']" [disabled]="true">Disabled</bit-tab-link>
</bit-tab-nav-bar>
<div class="tw-bg-transparent tw-text-semibold tw-text-center tw-text-main tw-py-10">