mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 16:23:44 +00:00
@@ -0,0 +1,135 @@
|
|||||||
|
import { Component } from "@angular/core";
|
||||||
|
import { TestBed } from "@angular/core/testing";
|
||||||
|
import { Router } from "@angular/router";
|
||||||
|
import { RouterTestingModule } from "@angular/router/testing";
|
||||||
|
import { MockProxy, mock } from "jest-mock-extended";
|
||||||
|
import { of } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
import { RouterService } from "@bitwarden/web-vault/app/core";
|
||||||
|
|
||||||
|
import { ProjectView } from "../models/view/project.view";
|
||||||
|
import { ProjectService } from "../projects/project.service";
|
||||||
|
|
||||||
|
import { projectAccessGuard } from "./project-access.guard";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: "",
|
||||||
|
standalone: false,
|
||||||
|
})
|
||||||
|
export class GuardedRouteTestComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: "",
|
||||||
|
standalone: false,
|
||||||
|
})
|
||||||
|
export class RedirectTestComponent {}
|
||||||
|
|
||||||
|
describe("Project Redirect Guard", () => {
|
||||||
|
let organizationService: MockProxy<OrganizationService>;
|
||||||
|
let routerService: MockProxy<RouterService>;
|
||||||
|
let projectServiceMock: MockProxy<ProjectService>;
|
||||||
|
let i18nServiceMock: MockProxy<I18nService>;
|
||||||
|
let toastService: MockProxy<ToastService>;
|
||||||
|
let router: Router;
|
||||||
|
let accountService: FakeAccountService;
|
||||||
|
const userId = Utils.newGuid() as UserId;
|
||||||
|
|
||||||
|
const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization;
|
||||||
|
const projectView = {
|
||||||
|
id: "123",
|
||||||
|
organizationId: "123",
|
||||||
|
name: "project-name",
|
||||||
|
creationDate: Date.now.toString(),
|
||||||
|
revisionDate: Date.now.toString(),
|
||||||
|
read: true,
|
||||||
|
write: true,
|
||||||
|
} as ProjectView;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
organizationService = mock<OrganizationService>();
|
||||||
|
routerService = mock<RouterService>();
|
||||||
|
projectServiceMock = mock<ProjectService>();
|
||||||
|
i18nServiceMock = mock<I18nService>();
|
||||||
|
toastService = mock<ToastService>();
|
||||||
|
accountService = mockAccountServiceWith(userId);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RouterTestingModule.withRoutes([
|
||||||
|
{
|
||||||
|
path: "sm/:organizationId/projects/:projectId",
|
||||||
|
component: GuardedRouteTestComponent,
|
||||||
|
canActivate: [projectAccessGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sm",
|
||||||
|
component: RedirectTestComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "sm/:organizationId/projects",
|
||||||
|
component: RedirectTestComponent,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: OrganizationService, useValue: organizationService },
|
||||||
|
{ provide: AccountService, useValue: accountService },
|
||||||
|
{ provide: RouterService, useValue: routerService },
|
||||||
|
{ provide: ProjectService, useValue: projectServiceMock },
|
||||||
|
{ provide: I18nService, useValue: i18nServiceMock },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to sm/{orgId}/projects/{projectId} if project exists", async () => {
|
||||||
|
// Arrange
|
||||||
|
organizationService.organizations$.mockReturnValue(of([smOrg1]));
|
||||||
|
projectServiceMock.getByProjectId.mockReturnValue(Promise.resolve(projectView));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await router.navigateByUrl("sm/123/projects/123");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(router.url).toBe("/sm/123/projects/123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to sm/projects if project does not exist", async () => {
|
||||||
|
// Arrange
|
||||||
|
organizationService.organizations$.mockReturnValue(of([smOrg1]));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await router.navigateByUrl("sm/123/projects/124");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(router.url).toBe("/sm/123/projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to sm/123/projects if exception occurs while looking for Project", async () => {
|
||||||
|
// Arrange
|
||||||
|
jest.spyOn(projectServiceMock, "getByProjectId").mockImplementation(() => {
|
||||||
|
throw new Error("Test error");
|
||||||
|
});
|
||||||
|
jest.spyOn(i18nServiceMock, "t").mockReturnValue("Project not found");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await router.navigateByUrl("sm/123/projects/123");
|
||||||
|
// Assert
|
||||||
|
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "error",
|
||||||
|
title: null,
|
||||||
|
message: "Project not found",
|
||||||
|
});
|
||||||
|
expect(router.url).toBe("/sm/123/projects");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
|
// @ts-strict-ignore
|
||||||
|
import { inject } from "@angular/core";
|
||||||
|
import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router";
|
||||||
|
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { ProjectService } from "../projects/project.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects to projects list if the user doesn't have access to project.
|
||||||
|
*/
|
||||||
|
export const projectAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
|
||||||
|
const projectService = inject(ProjectService);
|
||||||
|
const toastService = inject(ToastService);
|
||||||
|
const i18nService = inject(I18nService);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await projectService.getByProjectId(route.params.projectId, true);
|
||||||
|
if (project) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastService.showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: null,
|
||||||
|
message: i18nService.t("notFound", i18nService.t("project")),
|
||||||
|
});
|
||||||
|
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]);
|
||||||
|
}
|
||||||
|
return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]);
|
||||||
|
};
|
||||||
@@ -59,7 +59,10 @@ export class ProjectDialogComponent implements OnInit {
|
|||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const project: ProjectView = await this.projectService.getByProjectId(this.data.projectId);
|
const project: ProjectView = await this.projectService.getByProjectId(
|
||||||
|
this.data.projectId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.formGroup.setValue({ name: project.name });
|
this.formGroup.setValue({ name: project.name });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { ProjectResponse } from "./models/responses/project.response";
|
|||||||
export class ProjectService {
|
export class ProjectService {
|
||||||
protected _project = new Subject<ProjectView>();
|
protected _project = new Subject<ProjectView>();
|
||||||
project$ = this._project.asObservable();
|
project$ = this._project.asObservable();
|
||||||
|
private projectCache = new Map<string, Promise<ProjectView>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private keyService: KeyService,
|
private keyService: KeyService,
|
||||||
@@ -48,10 +49,24 @@ export class ProjectService {
|
|||||||
return await firstValueFrom(this.getOrganizationKey$(organizationId));
|
return await firstValueFrom(this.getOrganizationKey$(organizationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByProjectId(projectId: string): Promise<ProjectView> {
|
async getByProjectId(projectId: string, forceRequest: boolean): Promise<ProjectView> {
|
||||||
const r = await this.apiService.send("GET", "/projects/" + projectId, null, true, true);
|
if (forceRequest || !this.projectCache.has(projectId)) {
|
||||||
|
const request = this.apiService
|
||||||
|
.send("GET", `/projects/${projectId}`, null, true, true)
|
||||||
|
.then((r) => {
|
||||||
const projectResponse = new ProjectResponse(r);
|
const projectResponse = new ProjectResponse(r);
|
||||||
return await this.createProjectView(projectResponse);
|
return this.createProjectView(projectResponse);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// remove from cache if it failed, so future calls can retry
|
||||||
|
this.projectCache.delete(projectId);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.projectCache.set(projectId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.projectCache.get(projectId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(organizationId: string): Promise<ProjectListView[]> {
|
async getProjects(organizationId: string): Promise<ProjectListView[]> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<ng-container *ngIf="{ secrets: secrets$ | async } as projectSecrets">
|
<ng-container *ngIf="{ project: project$ | async, secrets: secrets$ | async } as projectSecrets">
|
||||||
<ng-container *ngIf="projectSecrets?.secrets && this.projectExists(); else spinner">
|
<ng-container *ngIf="projectSecrets?.secrets && projectSecrets?.project; else spinner">
|
||||||
<div
|
<div
|
||||||
*ngIf="projectSecrets.secrets?.length > 0 && this.writeAccess()"
|
*ngIf="projectSecrets.secrets?.length > 0 && projectSecrets.project?.write"
|
||||||
class="tw-float-right tw-mt-3 tw-items-center"
|
class="tw-float-right tw-mt-3 tw-items-center"
|
||||||
>
|
>
|
||||||
<button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
|
<button type="button" bitButton buttonType="secondary" (click)="openNewSecretDialog()">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<sm-secrets-list
|
<sm-secrets-list
|
||||||
*ngIf="projectSecrets.secrets?.length > 0 || this.writeAccess(); else contactAdmin"
|
*ngIf="projectSecrets.secrets?.length > 0 || projectSecrets.project?.write; else contactAdmin"
|
||||||
(deleteSecretsEvent)="openDeleteSecret($event)"
|
(deleteSecretsEvent)="openDeleteSecret($event)"
|
||||||
(newSecretEvent)="openNewSecretDialog()"
|
(newSecretEvent)="openNewSecretDialog()"
|
||||||
(editSecretEvent)="openEditSecret($event)"
|
(editSecretEvent)="openEditSecret($event)"
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Component, computed, inject, OnInit, Signal } from "@angular/core";
|
import { Component, OnInit } from "@angular/core";
|
||||||
import { ActivatedRoute, ROUTER_OUTLET_DATA } from "@angular/router";
|
import { ActivatedRoute } from "@angular/router";
|
||||||
import { combineLatestWith, firstValueFrom, Observable, startWith, switchMap } from "rxjs";
|
import {
|
||||||
|
combineLatest,
|
||||||
|
combineLatestWith,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
Observable,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getOrganizationById,
|
getOrganizationById,
|
||||||
@@ -32,7 +40,7 @@ import {
|
|||||||
} from "../../secrets/dialog/secret-view-dialog.component";
|
} from "../../secrets/dialog/secret-view-dialog.component";
|
||||||
import { SecretService } from "../../secrets/secret.service";
|
import { SecretService } from "../../secrets/secret.service";
|
||||||
import { SecretsListComponent } from "../../shared/secrets-list.component";
|
import { SecretsListComponent } from "../../shared/secrets-list.component";
|
||||||
|
import { ProjectService } from "../project.service";
|
||||||
@Component({
|
@Component({
|
||||||
selector: "sm-project-secrets",
|
selector: "sm-project-secrets",
|
||||||
templateUrl: "./project-secrets.component.html",
|
templateUrl: "./project-secrets.component.html",
|
||||||
@@ -45,12 +53,10 @@ export class ProjectSecretsComponent implements OnInit {
|
|||||||
private projectId: string;
|
private projectId: string;
|
||||||
protected project$: Observable<ProjectView>;
|
protected project$: Observable<ProjectView>;
|
||||||
private organizationEnabled: boolean;
|
private organizationEnabled: boolean;
|
||||||
protected project = inject(ROUTER_OUTLET_DATA) as Signal<ProjectView>;
|
|
||||||
readonly writeAccess = computed(() => this.project().write);
|
|
||||||
readonly projectExists = computed(() => !!this.project());
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
private projectService: ProjectService,
|
||||||
private secretService: SecretService,
|
private secretService: SecretService,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
@@ -61,9 +67,18 @@ export class ProjectSecretsComponent implements OnInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
const currentProjectEdited = this.projectService.project$.pipe(
|
||||||
|
filter((p) => p?.id === this.projectId),
|
||||||
|
startWith(null),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
|
||||||
|
switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId, false)),
|
||||||
|
);
|
||||||
|
|
||||||
this.secrets$ = this.secretService.secret$.pipe(
|
this.secrets$ = this.secretService.secret$.pipe(
|
||||||
startWith(null),
|
startWith(null),
|
||||||
combineLatestWith(this.route.params),
|
combineLatestWith(this.route.params, currentProjectEdited),
|
||||||
switchMap(async ([_, params]) => {
|
switchMap(async ([_, params]) => {
|
||||||
this.organizationId = params.organizationId;
|
this.organizationId = params.organizationId;
|
||||||
this.projectId = params.projectId;
|
this.projectId = params.projectId;
|
||||||
|
|||||||
@@ -36,4 +36,4 @@
|
|||||||
{{ "editProject" | i18n }}
|
{{ "editProject" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</app-header>
|
</app-header>
|
||||||
<router-outlet [routerOutletData]="this.project$"></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ export class ProjectComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
|
this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe(
|
||||||
switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)),
|
switchMap(([params, currentProj]) =>
|
||||||
|
this.projectService.getByProjectId(params.projectId, currentProj != null),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectId$ = this.route.params.pipe(map((p) => p.projectId));
|
const projectId$ = this.route.params.pipe(map((p) => p.projectId));
|
||||||
const organization$ = this.route.params.pipe(
|
const organization$ = this.route.params.pipe(
|
||||||
concatMap((params) =>
|
concatMap((params) =>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
import { RouterModule, Routes } from "@angular/router";
|
import { RouterModule, Routes } from "@angular/router";
|
||||||
|
|
||||||
|
import { projectAccessGuard } from "../guards/project-access.guard";
|
||||||
|
|
||||||
import { ProjectPeopleComponent } from "./project/project-people.component";
|
import { ProjectPeopleComponent } from "./project/project-people.component";
|
||||||
import { ProjectSecretsComponent } from "./project/project-secrets.component";
|
import { ProjectSecretsComponent } from "./project/project-secrets.component";
|
||||||
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
|
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
|
||||||
@@ -24,6 +26,7 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: "secrets",
|
path: "secrets",
|
||||||
component: ProjectSecretsComponent,
|
component: ProjectSecretsComponent,
|
||||||
|
canActivate: [projectAccessGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "people",
|
path: "people",
|
||||||
|
|||||||
@@ -110,10 +110,6 @@
|
|||||||
<i class="bwi bwi-fw bwi-pencil" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-pencil" aria-hidden="true"></i>
|
||||||
{{ "editProject" | i18n }}
|
{{ "editProject" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button *ngIf="project.write" type="button" bitMenuItem (click)="deleteProject(project.id)">
|
|
||||||
<i class="bwi bwi-fw bwi-trash tw-text-danger" aria-hidden="true"></i>
|
|
||||||
<span class="tw-text-danger">{{ "deleteProject" | i18n }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitMenuItem
|
bitMenuItem
|
||||||
@@ -123,6 +119,10 @@
|
|||||||
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
|
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
|
||||||
<span> {{ "viewEvents" | i18n }} </span>
|
<span> {{ "viewEvents" | i18n }} </span>
|
||||||
</button>
|
</button>
|
||||||
|
<button *ngIf="project.write" type="button" bitMenuItem (click)="deleteProject(project.id)">
|
||||||
|
<i class="bwi bwi-fw bwi-trash tw-text-danger" aria-hidden="true"></i>
|
||||||
|
<span class="tw-text-danger">{{ "deleteProject" | i18n }}</span>
|
||||||
|
</button>
|
||||||
</bit-menu>
|
</bit-menu>
|
||||||
</tr>
|
</tr>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
Reference in New Issue
Block a user