1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 19:23:52 +00:00

[AC-1011] Admin Console / Billing code ownership (#4973)

* refactor: move SCIM component to admin-console, refs EC-1011

* refactor: move scimProviderType to admin-console, refs EC-1011

* refactor: move scim-config.api to admin-console, refs EC-1011

* refactor: create models folder and nest existing api contents, refs EC-1011

* refactor: move scim-config to admin-console models, refs EC-1011

* refactor: move billing.component to billing, refs EC-1011

* refactor: remove nested app folder from new billing structure, refs EC-1011

* refactor: move organizations/billing to billing, refs EC-1011

* refactor: move add-credit and adjust-payment to billing/settings, refs EC-1011

* refactor: billing history/sync to billing, refs EC-1011

* refactor: move org plans, payment/method to billing/settings, refs EC-1011

* fix: update legacy file paths for payment-method and tax-info, refs EC-1011

* fix: update imports for scim component, refs EC-1011

* refactor: move subscription and tax-info into billing, refs EC-1011

* refactor: move user-subscription to billing, refs EC-1011

* refactor: move images/cards to billing and update base path, refs EC-1011

* refactor: move payment-method, plan subscription, and plan to billing, refs EC-1011

* refactor: move transaction-type to billing, refs EC-1011

* refactor: move billing-sync-config to billing, refs EC-1011

* refactor: move billing-sync and bit-pay-invoice request to billing, refs EC-1011

* refactor: move org subscription and tax info update requests to billing, refs EC-1011

* fix: broken paths to billing, refs EC-1011

* refactor: move payment request to billing, refs EC-1011

* fix: update remaining imports for payment-request, refs EC-1011

* refactor: move tax-info-update to billing, refs EC-1011

* refactor: move billing-payment, billing-history, and billing responses to billing, refs EC-1011

* refactor: move organization-subscription-responset to billing, refs EC-1011

* refactor: move payment and plan responses to billing, refs EC-1011

* refactor: move subscription response to billing ,refs EC-1011

* refactor: move tax info and rate responses to billing, refs EC-1011

* fix: update remaining path to base response for tax-rate response, refs EC-1011

* refactor: (browser) move organization-service to admin-console, refs EC-1011

* refactor: (browser) move organizaiton-service to admin-console, refs EC-1011

* refactor: (cli) move share command to admin-console, refs EC-1011

* refactor: move organization-collect request model to admin-console, refs EC-1011

* refactor: (web) move organization, collection/user responses to admin-console, refs EC-1011

* refactor: (cli) move selection-read-only to admin-console, refs EC-1011

* refactor: (desktop) move organization-filter to admin-console, refs EC-1011

* refactor: (web) move organization-switcher to admin-console, refs EC-1011

* refactor: (web) move access-selector to admin-console, refs EC-1011

* refactor: (web) move create folder to admin-console, refs EC-1011

* refactor: (web) move org guards folder to admin-console, refs EC-1011

* refactor: (web) move org layout to admin-console, refs EC-1011

* refactor: move manage collections to admin console, refs EC-1011

* refactor: (web) move collection-dialog to admin-console, refs EC-1011

* refactor: (web) move entity users/events and events component to admin-console, refs EC-1011

* refactor: (web) move groups/group-add-edit to admin-console, refs EC-1011

* refactor: (web) move manage, org-manage module, and user-confirm to admin-console, refs EC-1011

* refactor: (web) move people to admin-console, refs EC-1011

* refactor: (web) move reset-password to admin-console, refs EC-1011

* refactor: (web) move organization-routing and module to admin-console, refs EC-1011

* refactor: move admin-console and billing within app scope, refs EC-1011

* fix: update leftover merge conflicts, refs EC-1011

* refactor: (web) member-dialog to admin-console, refs EC-1011

* refactor: (web) move policies to admin-console, refs EC-1011

* refactor: (web) move reporting to admin-console, refs EC-1011

* refactor: (web) move settings to admin-console, refs EC-1011

* refactor: (web) move sponsorships to admin-console, refs EC-1011

* refactor: (web) move tools to admin-console, refs EC-1011

* refactor: (web) move users to admin-console, refs EC-1011

* refactor: (web) move collections to admin-console, refs EC-1011

* refactor: (web) move create-organization to admin-console, refs EC-1011

* refactor: (web) move licensed components to admin-console, refs EC-1011

* refactor: (web) move bit organization modules to admin-console, refs EC-1011

* fix: update leftover import statements for organizations.module, refs EC-1011

* refactor: (web) move personal vault and max timeout to admin-console, refs EC-1011

* refactor: (web) move providers to admin-console, refs EC-1011

* refactor: (libs) move organization service to admin-console, refs EC-1011

* refactor: (libs) move profile org/provider responses and other misc org responses to admin-console, refs EC-1011

* refactor: (libs) move provider request and selectionion-read-only request to admin-console, refs EC-1011

* fix: update missed import path for provider-user-update request, refs EC-1011

* refactor: (libs) move abstractions to admin-console, refs EC-1011

* refactor: (libs) move org/provider enums to admin-console, refs EC-1011

* fix: update downstream import statements from libs changes, refs EC-1011

* refactor: (libs) move data files to admin-console, refs EC-1011

* refactor: (libs) move domain to admin-console, refs EC-1011

* refactor: (libs) move request objects to admin-console, refs EC-1011

* fix: update downstream import changes from libs, refs EC-1011

* refactor: move leftover provider files to admin-console, refs EC-1011

* refactor: (browser) move group policy environment to admin-console, refs EC-1011

* fix: (browser) update downstream import statements, refs EC-1011

* fix: (desktop) update downstream libs moves, refs EC-1011

* fix: (cli) update downstream import changes from libs, refs EC-1011

* refactor: move org-auth related files to admin-console, refs EC-1011

* refactor: (libs) move request objects to admin-console, refs EC-1011

* refactor: move persmissions to admin-console, refs EC-1011

* refactor: move sponsored families to admin-console and fix libs changes, refs EC-1011

* refactor: move collections to admin-console, refs EC-1011

* refactor: move spec file back to spec scope, refs EC-1011

* fix: update downstream imports due to libs changes, refs EC-1011

* fix: udpate downstream import changes due to libs, refs EC-1011

* fix: update downstream imports due to libs changes, refs EC-1011

* fix: update downstream imports from libs changes, refs EC-1011

* fix: update path malformation in jslib-services.module, refs EC-1011

* fix: lint errors from improper casing, refs AC-1011

* fix: update downstream filename changes, refs AC-1011

* fix: (cli) update downstream filename changes, refs AC-1011

* fix: (desktop) update downstream filename changes, refs AC-1011

* fix: (browser) update downstream filename changes, refs AC-1011

* fix: lint errors, refs AC-1011

* fix: prettier, refs AC-1011

* fix: lint fixes for import order, refs AC-1011

* fix: update import path for provider user type, refs AC-1011

* fix: update new codes import paths for admin console structure, refs AC-1011

* fix: lint/prettier, refs AC-1011

* fix: update layout stories path, refs AC-1011

* fix: update comoponents card icons base variable in styles, refs AC-1011

* fix: update provider service path in permissions guard spec, refs AC-1011

* fix: update provider permission guard path, refs AC-1011

* fix: remove unecessary TODO for shared index export statement, refs AC-1011

* refactor: move browser-organization service and cli organization-user response out of admin-console, refs AC-1011

* refactor: move web/browser/desktop collections component to vault domain, refs AC-1011

* refactor: move organization.module out of admin-console scope, refs AC-1011

* fix: prettier, refs AC-1011

* refactor: move organizations-api-key.request out of admin-console scope, refs AC-1011
This commit is contained in:
Vincent Salucci
2023-03-22 10:03:50 -05:00
committed by GitHub
parent a7fea2ff3a
commit 780a563ce0
557 changed files with 1260 additions and 1246 deletions

View File

@@ -0,0 +1,67 @@
<div *ngIf="loaded && activeOrganization != null" class="tw-flex">
<button
class="tw-flex tw-items-center tw-border-none tw-bg-background-alt"
type="button"
id="pickerButton"
[appA11yTitle]="'organizationPicker' | i18n"
[bitMenuTriggerFor]="orgPickerMenu"
>
<bit-avatar [text]="activeOrganization.name"></bit-avatar>
<div class="tw-flex">
<div class="org-name tw-ml-3">
<span>{{ activeOrganization.name }}</span>
<small class="tw-text-muted">{{ "organization" | i18n }}</small>
</div>
<div class="tw-ml-3">
<i class="bwi bwi-angle-down tw-text-main" aria-hidden="true"></i>
</div>
</div>
</button>
<div>
<div
class="tw-ml-3 tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-text-danger"
*ngIf="!activeOrganization.enabled"
>
<div class="tw-py-2 tw-px-5">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "organizationIsDisabled" | i18n }}
</div>
</div>
<div
class="tw-ml-3 tw-rounded tw-border tw-border-solid tw-border-info-500 tw-text-info"
*ngIf="activeOrganization.isProviderUser"
>
<div class="tw-py-2 tw-px-5">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "accessingUsingProvider" | i18n : activeOrganization.providerName }}
</div>
</div>
</div>
<bit-menu #orgPickerMenu>
<ul aria-labelledby="pickerButton" class="tw-m-0 tw-p-0">
<li
*ngFor="let org of organizations$ | async"
class="tw-flex tw-list-none tw-flex-col"
role="none"
>
<a bitMenuItem [routerLink]="['/organizations', org.id]">
<i
class="bwi bwi-check mr-2"
[ngClass]="org.id === activeOrganization.id ? 'visible' : 'invisible'"
>
<span class="tw-sr-only">{{ "currentOrganization" | i18n }}</span>
</i>
{{ org.name }}
</a>
</li>
<bit-menu-divider></bit-menu-divider>
<li class="tw-list-none" role="none">
<a bitMenuItem routerLink="/create-organization">
<i class="bwi bwi-plus mr-2"></i>
{{ "newOrganization" | i18n }}</a
>
</li>
</ul>
</bit-menu>
</div>

View File

@@ -0,0 +1,34 @@
import { Component, Input, OnInit } from "@angular/core";
import { map, Observable } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import {
canAccessAdmin,
isNotProviderUser,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Utils } from "@bitwarden/common/misc/utils";
@Component({
selector: "app-organization-switcher",
templateUrl: "organization-switcher.component.html",
})
export class OrganizationSwitcherComponent implements OnInit {
constructor(private organizationService: OrganizationService, private i18nService: I18nService) {}
@Input() activeOrganization: Organization = null;
organizations$: Observable<Organization[]>;
loaded = false;
async ngOnInit() {
this.organizations$ = this.organizationService.organizations$.pipe(
map((orgs) => orgs.filter(isNotProviderUser)),
canAccessAdmin(this.i18nService),
map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name")))
);
this.loaded = true;
}
}

View File

@@ -0,0 +1,13 @@
import { BasePolicy } from "../organizations/policies";
export class PolicyListService {
private policies: BasePolicy[] = [];
addPolicies(policies: BasePolicy[]) {
this.policies.push(...policies);
}
getPolicies(): BasePolicy[] {
return this.policies;
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { OrganizationInformationComponent } from "./organization-information.component";
@NgModule({
imports: [SharedModule],
declarations: [OrganizationInformationComponent],
exports: [OrganizationInformationComponent],
})
export class OrganizationCreateModule {}

View File

@@ -0,0 +1,38 @@
<form #form [formGroup]="formGroup" *ngIf="nameOnly">
<bit-form-field>
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="name" />
</bit-form-field>
</form>
<form #form [formGroup]="formGroup" *ngIf="!nameOnly">
<h2>{{ "generalInformation" | i18n }}</h2>
<div class="tw-flex tw-w-full tw-space-x-4" *ngIf="createOrganization">
<bit-form-field class="tw-w-1/2">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="name" />
</bit-form-field>
<bit-form-field class="tw-w-1/2">
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input bitInput type="email" formControlName="billingEmail" />
</bit-form-field>
<bit-form-field class="tw-w-1/2" *ngIf="isProvider">
<bit-label>{{ "clientOwnerEmail" | i18n }}</bit-label>
<input bitInput type="email" formControlName="clientOwnerEmail" />
</bit-form-field>
</div>
<div *ngIf="!isProvider && !acceptingSponsorship">
<input
type="checkbox"
name="businessOwned"
formControlName="businessOwned"
(change)="changedBusinessOwned.emit()"
/>
<bit-label for="businessOwned" class="tw-mb-3">{{ "accountOwnedBusiness" | i18n }}</bit-label>
<div class="tw-mt-4" *ngIf="formGroup.controls['businessOwned'].value">
<bit-form-field class="tw-w-1/2">
<bit-label>{{ "businessName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="businessName" />
</bit-form-field>
</div>
</div>
</form>

View File

@@ -0,0 +1,15 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { UntypedFormGroup } from "@angular/forms";
@Component({
selector: "app-org-info",
templateUrl: "organization-information.component.html",
})
export class OrganizationInformationComponent {
@Input() nameOnly = false;
@Input() createOrganization = true;
@Input() isProvider = false;
@Input() acceptingSponsorship = false;
@Input() formGroup: UntypedFormGroup;
@Output() changedBusinessOwned = new EventEmitter<void>();
}

View File

@@ -0,0 +1,163 @@
import {
ActivatedRouteSnapshot,
convertToParamMap,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { OrganizationPermissionsGuard } from "./org-permissions.guard";
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props
);
describe("Organization Permissions Guard", () => {
let router: MockProxy<Router>;
let organizationService: MockProxy<OrganizationService>;
let state: MockProxy<RouterStateSnapshot>;
let route: MockProxy<ActivatedRouteSnapshot>;
let organizationPermissionsGuard: OrganizationPermissionsGuard;
beforeEach(() => {
router = mock<Router>();
organizationService = mock<OrganizationService>();
state = mock<RouterStateSnapshot>();
route = mock<ActivatedRouteSnapshot>({
params: {
organizationId: orgFactory().id,
},
data: {
organizationPermissions: null,
},
});
organizationPermissionsGuard = new OrganizationPermissionsGuard(
router,
organizationService,
mock<PlatformUtilsService>(),
mock<I18nService>(),
mock<SyncService>()
);
});
it("blocks navigation if organization does not exist", async () => {
organizationService.get.mockReturnValue(null);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).not.toBe(true);
});
it("permits navigation if no permissions are specified", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).toBe(true);
});
it("permits navigation if the user has permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((org) => true);
route.data = {
organizationPermissions: permissionsCallback,
};
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).toBe(true);
});
describe("if the user does not have permissions", () => {
it("and there is no Item ID, block navigation", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((org) => false);
route.data = {
organizationPermissions: permissionsCallback,
};
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({}),
}),
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).not.toBe(true);
});
it("and there is an Item ID, redirect to the item in the individual vault", async () => {
route.data = {
organizationPermissions: (org: Organization) => false,
};
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({
itemId: "myItemId",
}),
}),
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(router.createUrlTree).toHaveBeenCalledWith(["/vault"], {
queryParams: { itemId: "myItemId" },
});
expect(actual).not.toBe(true);
});
});
describe("given a disabled organization", () => {
it("blocks navigation if user is not an owner", async () => {
const org = orgFactory({
type: OrganizationUserType.Admin,
enabled: false,
});
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).not.toBe(true);
});
it("permits navigation if user is an owner", async () => {
const org = orgFactory({
type: OrganizationUserType.Owner,
enabled: false,
});
organizationService.get.calledWith(org.id).mockReturnValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).toBe(true);
});
});
});

View File

@@ -0,0 +1,70 @@
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
@Injectable({
providedIn: "root",
})
export class OrganizationPermissionsGuard implements CanActivate {
constructor(
private router: Router,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private syncService: SyncService
) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
// TODO: We need to fix this issue once and for all.
if ((await this.syncService.getLastSync()) == null) {
await this.syncService.fullSync(false);
}
const org = this.organizationService.get(route.params.organizationId);
if (org == null) {
return this.router.createUrlTree(["/"]);
}
if (!org.isOwner && !org.enabled) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("organizationIsDisabled")
);
return this.router.createUrlTree(["/"]);
}
const permissionsCallback: (organization: Organization) => boolean =
route.data?.organizationPermissions;
const hasPermissions = permissionsCallback == null || permissionsCallback(org);
if (!hasPermissions) {
// Handle linkable ciphers for organizations the user only has view access to
// https://bitwarden.atlassian.net/browse/EC-203
const cipherId =
state.root.queryParamMap.get("itemId") || state.root.queryParamMap.get("cipherId");
if (cipherId) {
return this.router.createUrlTree(["/vault"], {
queryParams: {
itemId: cipherId,
},
});
}
this.platformUtilsService.showToast("error", null, this.i18nService.t("accessDenied"));
return canAccessOrgAdmin(org)
? this.router.createUrlTree(["/organizations", org.id])
: this.router.createUrlTree(["/"]);
}
return true;
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router";
import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@Injectable({
providedIn: "root",
})
export class OrganizationRedirectGuard implements CanActivate {
constructor(private router: Router, private organizationService: OrganizationService) {}
async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const org = this.organizationService.get(route.params.organizationId);
const customRedirect = route.data?.autoRedirectCallback;
if (customRedirect) {
let redirectPath = customRedirect(org);
if (typeof redirectPath === "string") {
redirectPath = [redirectPath];
}
return this.router.createUrlTree([state.url, ...redirectPath]);
}
if (canAccessOrgAdmin(org)) {
return this.router.createUrlTree(["/organizations", org.id]);
}
return this.router.createUrlTree(["/"]);
}
}

View File

@@ -0,0 +1,34 @@
<app-navbar></app-navbar>
<div class="org-nav !tw-h-32" *ngIf="organization$ | async as organization">
<div class="container d-flex">
<div class="d-flex flex-column">
<app-organization-switcher
class="my-auto pl-1"
[activeOrganization]="organization"
></app-organization-switcher>
<bit-tab-nav-bar class="-tw-mb-px">
<bit-tab-link *ngIf="canShowVaultTab(organization)" route="vault">{{
"vault" | i18n
}}</bit-tab-link>
<bit-tab-link *ngIf="canShowMembersTab(organization)" route="members">{{
"members" | i18n
}}</bit-tab-link>
<bit-tab-link *ngIf="canShowGroupsTab(organization)" route="groups">{{
"groups" | i18n
}}</bit-tab-link>
<bit-tab-link *ngIf="canShowReportsTab(organization)" route="reporting">
{{ getReportTabLabel(organization) | i18n }}
</bit-tab-link>
<bit-tab-link *ngIf="canShowBillingTab(organization)" route="billing">{{
"billing" | i18n
}}</bit-tab-link>
<bit-tab-link *ngIf="canShowSettingsTab(organization)" route="settings">{{
"settings" | i18n
}}</bit-tab-link>
</bit-tab-nav-bar>
</div>
</div>
</div>
<router-outlet></router-outlet>
<app-footer></app-footer>

View File

@@ -0,0 +1,75 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs";
import {
canAccessBillingTab,
canAccessGroupsTab,
canAccessMembersTab,
canAccessReportingTab,
canAccessSettingsTab,
canAccessVaultTab,
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Component({
selector: "app-organization-layout",
templateUrl: "organization-layout.component.html",
})
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
organization$: Observable<Organization>;
private _destroy = new Subject<void>();
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
document.body.classList.remove("layout_frontend");
this.organization$ = this.route.params
.pipe(takeUntil(this._destroy))
.pipe<string>(map((p) => p.organizationId))
.pipe(
mergeMap((id) => {
return this.organizationService.organizations$
.pipe(takeUntil(this._destroy))
.pipe(getOrganizationById(id));
})
);
}
ngOnDestroy() {
this._destroy.next();
this._destroy.complete();
}
canShowVaultTab(organization: Organization): boolean {
return canAccessVaultTab(organization);
}
canShowSettingsTab(organization: Organization): boolean {
return canAccessSettingsTab(organization);
}
canShowMembersTab(organization: Organization): boolean {
return canAccessMembersTab(organization);
}
canShowGroupsTab(organization: Organization): boolean {
return canAccessGroupsTab(organization);
}
canShowReportsTab(organization: Organization): boolean {
return canAccessReportingTab(organization);
}
canShowBillingTab(organization: Organization): boolean {
return canAccessBillingTab(organization);
}
getReportTabLabel(organization: Organization): string {
return organization.useEvents ? "reporting" : "reports";
}
}

View File

@@ -0,0 +1,106 @@
<div class="page-header d-flex">
<h1>{{ "collections" | i18n }}</h1>
<div class="ml-auto d-flex">
<div>
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
[(ngModel)]="searchText"
/>
</div>
<button
type="button"
*ngIf="this.canCreate"
class="btn btn-sm btn-outline-primary ml-3"
(click)="add()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newCollection" | i18n }}
</button>
</div>
</div>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="
!loading &&
(isPaging()
? pagedCollections
: (collections | search : searchText : 'name' : 'id')) as searchedCollections
"
>
<p *ngIf="!searchedCollections.length">{{ "noCollectionsInList" | i18n }}</p>
<table
class="table table-hover table-list"
*ngIf="searchedCollections.length"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<tbody>
<tr *ngFor="let c of searchedCollections">
<td>
<a href="#" appStopClick (click)="edit(c)">{{ c.name }}</a>
</td>
<td class="table-list-options">
<div class="dropdown" appListDropdown *ngIf="this.canEdit(c) || this.canDelete(c)">
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a
class="dropdown-item"
href="#"
appStopClick
*ngIf="this.canEdit(c)"
(click)="edit(c)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</a>
<a
class="dropdown-item"
href="#"
appStopClick
*ngIf="this.canEdit(c)"
(click)="users(c)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "users" | i18n }}
</a>
<a
class="dropdown-item text-danger"
href="#"
appStopClick
*ngIf="this.canDelete(c)"
(click)="delete(c)"
>
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</a>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #usersTemplate></ng-template>

View File

@@ -0,0 +1,298 @@
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, lastValueFrom } from "rxjs";
import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CollectionData } from "@bitwarden/common/admin-console/models/data/collection.data";
import { Collection } from "@bitwarden/common/admin-console/models/domain/collection";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import {
CollectionDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/admin-console/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { ProductType } from "@bitwarden/common/enums/productType";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import {
DialogService,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/components";
import { EntityUsersComponent } from "../manage/entity-users.component";
import {
CollectionDialogResult,
openCollectionDialog,
} from "../shared/components/collection-dialog";
@Component({
selector: "app-org-manage-collections",
templateUrl: "collections.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class CollectionsComponent implements OnInit {
@ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef;
@ViewChild("usersTemplate", { read: ViewContainerRef, static: true })
usersModalRef: ViewContainerRef;
loading = true;
organization: Organization;
canCreate = false;
organizationId: string;
collections: CollectionView[];
assignedCollections: CollectionView[];
pagedCollections: CollectionView[];
searchText: string;
protected didScroll = false;
protected pageSize = 100;
private pagedCollectionsCount = 0;
constructor(
private apiService: ApiService,
private route: ActivatedRoute,
private collectionService: CollectionService,
private modalService: ModalService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
private logService: LogService,
private organizationService: OrganizationService,
private dialogService: DialogService,
private router: Router
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
await this.load();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
this.searchText = qParams.search;
});
});
}
async load() {
this.organization = await this.organizationService.get(this.organizationId);
this.canCreate = this.organization.canCreateNewCollections;
const decryptCollections = async (r: ListResponse<CollectionResponse>) => {
const collections = r.data
.filter((c) => c.organizationId === this.organizationId)
.map((d) => new Collection(new CollectionData(d as CollectionDetailsResponse)));
return await this.collectionService.decryptMany(collections);
};
if (this.organization.canViewAssignedCollections) {
const response = await this.apiService.getUserCollections();
this.assignedCollections = await decryptCollections(response);
}
if (this.organization.canViewAllCollections) {
const response = await this.apiService.getCollections(this.organizationId);
this.collections = await decryptCollections(response);
} else {
this.collections = this.assignedCollections;
}
this.resetPaging();
this.loading = false;
}
loadMore() {
if (!this.collections || this.collections.length <= this.pageSize) {
return;
}
const pagedLength = this.pagedCollections.length;
let pagedSize = this.pageSize;
if (pagedLength === 0 && this.pagedCollectionsCount > this.pageSize) {
pagedSize = this.pagedCollectionsCount;
}
if (this.collections.length > pagedLength) {
this.pagedCollections = this.pagedCollections.concat(
this.collections.slice(pagedLength, pagedLength + pagedSize)
);
}
this.pagedCollectionsCount = this.pagedCollections.length;
this.didScroll = this.pagedCollections.length > this.pageSize;
}
async edit(collection?: CollectionView) {
const canCreate = collection == undefined && this.canCreate;
const canEdit = collection != undefined && this.canEdit(collection);
const canDelete = collection != undefined && this.canDelete(collection);
if (!(canCreate || canEdit || canDelete)) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
return;
}
if (
!collection &&
this.organization.planProductType === ProductType.Free &&
this.collections.length === this.organization.maxCollections
) {
// Show org upgrade modal
// It might be worth creating a simple
// org upgrade dialog service to launch the dialog here and in the people.comp
// once the enterprise pod is done w/ their organization module refactor.
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canManageBilling
? "freeOrgMaxCollectionReachedManageBilling"
: "freeOrgMaxCollectionReachedNoManageBilling",
this.organization.maxCollections
),
type: SimpleDialogType.PRIMARY,
};
if (this.organization.canManageBilling) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts);
firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => {
if (!result) {
return;
}
if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) {
this.router.navigate(
["/organizations", this.organization.id, "billing", "subscription"],
{ queryParams: { upgrade: true } }
);
}
});
return;
}
const dialog = openCollectionDialog(this.dialogService, {
data: { collectionId: collection?.id, organizationId: this.organizationId },
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionDialogResult.Saved || result === CollectionDialogResult.Deleted) {
this.load();
}
}
add() {
this.edit(null);
}
async delete(collection: CollectionView) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
collection.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.apiService.deleteCollection(this.organizationId, collection.id);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", collection.name)
);
this.removeCollection(collection);
} catch (e) {
this.logService.error(e);
this.platformUtilsService.showToast("error", null, this.i18nService.t("missingPermissions"));
}
}
async users(collection: CollectionView) {
const [modal] = await this.modalService.openViewRef(
EntityUsersComponent,
this.usersModalRef,
(comp) => {
comp.organizationId = this.organizationId;
comp.entity = "collection";
comp.entityId = collection.id;
comp.entityName = collection.name;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onEditedUsers.subscribe(() => {
this.load();
modal.close();
});
}
);
}
async resetPaging() {
this.pagedCollections = [];
this.loadMore();
}
isSearching() {
return this.searchService.isSearchable(this.searchText);
}
isPaging() {
const searching = this.isSearching();
if (searching && this.didScroll) {
this.resetPaging();
}
return !searching && this.collections && this.collections.length > this.pageSize;
}
canEdit(collection: CollectionView) {
if (this.organization.canEditAnyCollection) {
return true;
}
if (
this.organization.canEditAssignedCollections &&
this.assignedCollections.some((c) => c.id === collection.id)
) {
return true;
}
return false;
}
canDelete(collection: CollectionView) {
if (this.organization.canDeleteAnyCollection) {
return true;
}
if (
this.organization.canDeleteAssignedCollections &&
this.assignedCollections.some((c) => c.id === collection.id)
) {
return true;
}
return false;
}
private removeCollection(collection: CollectionView) {
const index = this.collections.indexOf(collection);
if (index > -1) {
this.collections.splice(index, 1);
this.resetPaging();
}
}
}

View File

@@ -0,0 +1,118 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="eventLogsTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="eventLogsTitle">
{{ "eventLogs" | i18n }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="modal-body" *ngIf="loaded">
<div class="d-flex">
<div class="form-inline">
<label class="sr-only" for="start">{{ "startDate" | i18n }}</label>
<input
type="datetime-local"
class="form-control form-control-sm"
id="start"
placeholder="{{ 'startDate' | i18n }}"
[(ngModel)]="start"
placeholder="YYYY-MM-DDTHH:MM"
/>
<span class="mx-2">-</span>
<label class="sr-only" for="end">{{ "endDate" | i18n }}</label>
<input
type="datetime-local"
class="form-control form-control-sm"
id="end"
placeholder="{{ 'endDate' | i18n }}"
[(ngModel)]="end"
placeholder="YYYY-MM-DDTHH:MM"
/>
</div>
<button
#refreshBtn
[appApiAction]="refreshPromise"
type="button"
class="btn btn-sm btn-outline-primary ml-3"
(click)="loadEvents(true)"
[disabled]="loaded && $any(refreshBtn).loading"
>
<i
class="bwi bwi-refresh bwi-fw"
[ngClass]="{ 'bwi-spin': loaded && $any(refreshBtn).loading }"
aria-hidden="true"
></i>
{{ "refresh" | i18n }}
</button>
</div>
<hr />
<div *ngIf="!events || !events.length">
{{ "noEventsInList" | i18n }}
</div>
<table class="table table-hover mb-0" *ngIf="events && events.length">
<thead>
<tr>
<th class="border-top-0" width="210">{{ "timestamp" | i18n }}</th>
<th class="border-top-0" width="40">
<span class="sr-only">{{ "device" | i18n }}</span>
</th>
<th class="border-top-0" width="150" *ngIf="showUser">{{ "user" | i18n }}</th>
<th class="border-top-0">{{ "event" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let e of events">
<td>{{ e.date | date : "medium" }}</td>
<td>
<i
class="text-muted bwi bwi-lg {{ e.appIcon }}"
title="{{ e.appName }}, {{ e.ip }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ e.appName }}, {{ e.ip }}</span>
</td>
<td *ngIf="showUser">
<span appA11yTitle="{{ e.userEmail }}">{{ e.userName }}</span>
</td>
<td [innerHTML]="e.message"></td>
</tr>
</tbody>
</table>
<button
#moreBtn
[appApiAction]="morePromise"
type="button"
class="btn btn-block btn-link btn-submit"
(click)="loadEvents(false)"
[disabled]="loaded && $any(moreBtn).loading"
*ngIf="continuationToken"
>
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,156 @@
import { Component, Input, OnInit } from "@angular/core";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { EventService } from "../../../core";
@Component({
selector: "app-entity-events",
templateUrl: "entity-events.component.html",
})
export class EntityEventsComponent implements OnInit {
@Input() name: string;
@Input() entity: "user" | "cipher";
@Input() entityId: string;
@Input() organizationId: string;
@Input() providerId: string;
@Input() showUser = false;
loading = true;
loaded = false;
events: any[];
start: string;
end: string;
continuationToken: string;
refreshPromise: Promise<any>;
morePromise: Promise<any>;
private orgUsersUserIdMap = new Map<string, any>();
private orgUsersIdMap = new Map<string, any>();
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private eventService: EventService,
private platformUtilsService: PlatformUtilsService,
private userNamePipe: UserNamePipe,
private logService: LogService,
private organizationUserService: OrganizationUserService
) {}
async ngOnInit() {
const defaultDates = this.eventService.getDefaultDateFilters();
this.start = defaultDates[0];
this.end = defaultDates[1];
await this.load();
}
async load() {
if (this.showUser) {
const response = await this.organizationUserService.getAllUsers(this.organizationId);
response.data.forEach((u) => {
const name = this.userNamePipe.transform(u);
this.orgUsersIdMap.set(u.id, { name: name, email: u.email });
this.orgUsersUserIdMap.set(u.userId, { name: name, email: u.email });
});
}
await this.loadEvents(true);
this.loaded = true;
}
async loadEvents(clearExisting: boolean) {
if (this.refreshPromise != null || this.morePromise != null) {
return;
}
let dates: string[] = null;
try {
dates = this.eventService.formatDateFilters(this.start, this.end);
} catch (e) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("invalidDateRange")
);
return;
}
this.loading = true;
let response: ListResponse<EventResponse>;
try {
let promise: Promise<any>;
if (this.entity === "user" && this.providerId) {
promise = this.apiService.getEventsProviderUser(
this.providerId,
this.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
} else if (this.entity === "user") {
promise = this.apiService.getEventsOrganizationUser(
this.organizationId,
this.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
} else {
promise = this.apiService.getEventsCipher(
this.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
}
if (clearExisting) {
this.refreshPromise = promise;
} else {
this.morePromise = promise;
}
response = await promise;
} catch (e) {
this.logService.error(e);
}
this.continuationToken = response.continuationToken;
const events = await Promise.all(
response.data.map(async (r) => {
const userId = r.actingUserId == null ? r.userId : r.actingUserId;
const eventInfo = await this.eventService.getEventInfo(r);
const user =
this.showUser && userId != null && this.orgUsersUserIdMap.has(userId)
? this.orgUsersUserIdMap.get(userId)
: null;
return {
message: eventInfo.message,
appIcon: eventInfo.appIcon,
appName: eventInfo.appName,
userId: userId,
userName: user != null ? user.name : this.showUser ? this.i18nService.t("unknown") : null,
userEmail: user != null ? user.email : this.showUser ? "" : null,
date: r.date,
ip: r.ipAddress,
type: r.type,
};
})
);
if (!clearExisting && this.events != null && this.events.length > 0) {
this.events = this.events.concat(events);
} else {
this.events = events;
}
this.loading = false;
this.morePromise = null;
this.refreshPromise = null;
}
}

View File

@@ -0,0 +1,173 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="userAccessTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="userAccessTitle">
{{ "userAccess" | i18n }}
<small>{{ entityName }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="loading || !users">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<cdk-virtual-scroll-viewport
itemSize="46"
minBufferPx="600"
maxBufferPx="1200"
[style]="scrollViewportStyle"
>
<div class="modal-body" *ngIf="!loading && users && searchedUsers">
<div class="d-flex">
<div class="mr-3">
<label class="sr-only" for="search">{{ "search" | i18n }}</label>
<input
type="search"
class="form-control form-control-sm"
id="search"
placeholder="{{ 'search' | i18n }}"
name="SearchText"
[(ngModel)]="searchText"
/>
</div>
<div class="btn-group btn-group-sm" role="group">
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: !showSelected }"
(click)="filterSelected(false)"
>
{{ "all" | i18n }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
[ngClass]="{ active: showSelected }"
(click)="filterSelected(true)"
>
{{ "selected" | i18n }}
<span bitBadge badgeType="info" *ngIf="selectedCount">{{ selectedCount }}</span>
</button>
</div>
</div>
<ng-container *ngIf="!searchedUsers.length">
<hr />
{{ "noUsersInList" | i18n }}
</ng-container>
<table class="table table-hover table-list mb-0" [hidden]="!searchedUsers.length">
<thead>
<tr>
<th>&nbsp;</th>
<th>&nbsp;</th>
<th>{{ "name" | i18n }}</th>
<th *ngIf="entity === 'collection'">&nbsp;</th>
<th>{{ "userType" | i18n }}</th>
<th width="100" class="text-center" *ngIf="entity === 'collection'">
{{ "hidePasswords" | i18n }}
</th>
<th width="100" class="text-center" *ngIf="entity === 'collection'">
{{ "readOnly" | i18n }}
</th>
</tr>
</thead>
<tbody>
<tr *cdkVirtualFor="let u of searchedUsers" class="">
<td class="table-list-checkbox" (click)="check(u)">
<input
type="checkbox"
[(ngModel)]="u.checked"
name="{{ u.id.substr(0, 8) }}_Checked"
[disabled]="entity === 'collection' && u.accessAll"
(change)="selectedChanged(u)"
appStopProp
/>
</td>
<td width="30" (click)="check(u)">
<bit-avatar [text]="u | userName" [id]="u.id" size="small"></bit-avatar>
</td>
<td>
{{ u.email }}
<span
bitBadge
badgeType="secondary"
*ngIf="u.status === organizationUserStatusType.Invited"
>{{ "invited" | i18n }}</span
>
<span
bitBadge
badgeType="warning"
*ngIf="u.status === organizationUserStatusType.Accepted"
>{{ "accepted" | i18n }}</span
>
<small class="text-muted d-block" *ngIf="u.name">{{ u.name }}</small>
</td>
<td *ngIf="entity === 'collection'">
<ng-container *ngIf="u.accessAll">
<i
class="bwi bwi-filter"
title="{{ 'userAccessAllItems' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "userAccessAllItems" | i18n }}</span>
</ng-container>
</td>
<td>
<span *ngIf="u.type === organizationUserType.Owner">{{ "owner" | i18n }}</span>
<span *ngIf="u.type === organizationUserType.Admin">{{ "admin" | i18n }}</span>
<span *ngIf="u.type === organizationUserType.Manager">{{
"manager" | i18n
}}</span>
<span *ngIf="u.type === organizationUserType.User">{{ "user" | i18n }}</span>
<span *ngIf="u.type === organizationUserType.Custom">{{ "custom" | i18n }}</span>
</td>
<td class="text-center" *ngIf="entity === 'collection'">
<input
type="checkbox"
[(ngModel)]="u.hidePasswords"
name="{{ u.id.substr(0, 8) }}_HidePasswords"
[disabled]="u.accessAll || !u.checked"
/>
</td>
<td class="text-center" *ngIf="entity === 'collection'">
<input
type="checkbox"
[(ngModel)]="u.readOnly"
name="{{ u.id.substr(0, 8) }}_ReadOnly"
[disabled]="u.accessAll || !u.checked"
/>
</td>
</tr>
</tbody>
</table>
</div>
</cdk-virtual-scroll-viewport>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,158 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { Utils } from "@bitwarden/common/misc/utils";
@Component({
selector: "app-entity-users",
templateUrl: "entity-users.component.html",
providers: [SearchPipe],
})
export class EntityUsersComponent implements OnInit {
@Input() entity: "group" | "collection";
@Input() entityId: string;
@Input() entityName: string;
@Input() organizationId: string;
@Output() onEditedUsers = new EventEmitter();
organizationUserType = OrganizationUserType;
organizationUserStatusType = OrganizationUserStatusType;
showSelected = false;
loading = true;
formPromise: Promise<any>;
selectedCount = 0;
searchText: string;
private allUsers: OrganizationUserUserDetailsResponse[] = [];
constructor(
private search: SearchPipe,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private organizationUserService: OrganizationUserService,
private logService: LogService
) {}
async ngOnInit() {
await this.loadUsers();
this.loading = false;
}
get users() {
if (this.showSelected) {
return this.allUsers.filter((u) => (u as any).checked);
} else {
return this.allUsers;
}
}
get searchedUsers() {
return this.search.transform(this.users, this.searchText, "name", "email", "id");
}
get scrollViewportStyle() {
return `min-height: 120px; height: ${120 + this.searchedUsers.length * 46}px`;
}
async loadUsers() {
const users = await this.organizationUserService.getAllUsers(this.organizationId);
this.allUsers = users.data.map((r) => r).sort(Utils.getSortFunction(this.i18nService, "email"));
if (this.entity === "group") {
const response = await this.apiService.getGroupUsers(this.organizationId, this.entityId);
if (response != null && users.data.length > 0) {
response.forEach((s) => {
const user = users.data.filter((u) => u.id === s);
if (user != null && user.length > 0) {
(user[0] as any).checked = true;
}
});
}
} else if (this.entity === "collection") {
const response = await this.apiService.getCollectionUsers(this.organizationId, this.entityId);
if (response != null && users.data.length > 0) {
response.forEach((s) => {
const user = users.data.filter((u) => !u.accessAll && u.id === s.id);
if (user != null && user.length > 0) {
(user[0] as any).checked = true;
(user[0] as any).readOnly = s.readOnly;
(user[0] as any).hidePasswords = s.hidePasswords;
}
});
}
}
this.allUsers.forEach((u) => {
if (this.entity === "collection" && u.accessAll) {
(u as any).checked = true;
}
if ((u as any).checked) {
this.selectedCount++;
}
});
}
check(u: OrganizationUserUserDetailsResponse) {
if (this.entity === "collection" && u.accessAll) {
return;
}
(u as any).checked = !(u as any).checked;
this.selectedChanged(u);
}
selectedChanged(u: OrganizationUserUserDetailsResponse) {
if ((u as any).checked) {
this.selectedCount++;
} else {
if (this.entity === "collection") {
(u as any).readOnly = false;
(u as any).hidePasswords = false;
}
this.selectedCount--;
}
}
filterSelected(showSelected: boolean) {
this.showSelected = showSelected;
}
async submit() {
try {
if (this.entity === "group") {
const selections = this.users.filter((u) => (u as any).checked).map((u) => u.id);
this.formPromise = this.apiService.putGroupUsers(
this.organizationId,
this.entityId,
selections
);
} else {
const selections = this.users
.filter((u) => (u as any).checked && !u.accessAll)
.map(
(u) =>
new SelectionReadOnlyRequest(u.id, !!(u as any).readOnly, !!(u as any).hidePasswords)
);
this.formPromise = this.apiService.putCollectionUsers(
this.organizationId,
this.entityId,
selections
);
}
await this.formPromise;
this.platformUtilsService.showToast("success", null, this.i18nService.t("updatedUsers"));
this.onEditedUsers.emit();
} catch (e) {
this.logService.error(e);
}
}
}

View File

@@ -0,0 +1,109 @@
<div class="tw-mb-4">
<h1>{{ "eventLogs" | i18n }}</h1>
<div class="tw-mt-4 tw-flex tw-items-center">
<bit-form-field>
<bit-label>{{ "from" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
placeholder="{{ 'startDate' | i18n }}"
[(ngModel)]="start"
(change)="dirtyDates = true"
/>
</bit-form-field>
<span class="tw-mx-2">-</span>
<bit-form-field>
<bit-label>{{ "to" | i18n }}</bit-label>
<input
bitInput
type="datetime-local"
placeholder="{{ 'endDate' | i18n }}"
[(ngModel)]="end"
(change)="dirtyDates = true"
/>
</bit-form-field>
<form #refreshForm [appApiAction]="refreshPromise">
<button
class="tw-mx-3 tw-mt-1"
type="button"
bitButton
buttonType="primary"
(click)="loadEvents(true)"
[disabled]="loaded && refreshForm.loading"
>
{{ "update" | i18n }}
</button>
</form>
<form #exportForm [appApiAction]="exportPromise">
<button
type="button"
class="tw-mt-1"
bitButton
[ngClass]="{ loading: exportForm.loading }"
(click)="exportEvents()"
[disabled]="(loaded && exportForm.loading) || dirtyDates"
>
<span>{{ "export" | i18n }}</span>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-sign-in': !exportForm.loading,
'bwi-spinner bwi-spin': exportForm.loading
}"
></i>
</button>
</form>
</div>
</div>
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="loaded">
<p *ngIf="!events || !events.length">{{ "noEventsInList" | i18n }}</p>
<bit-table *ngIf="events && events.length">
<ng-container header>
<tr>
<th bitCell>{{ "timestamp" | i18n }}</th>
<th bitCell>{{ "client" | i18n }}</th>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "event" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let e of events" alignContent="top">
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date : "medium" }}</td>
<td bitCell>
<span title="{{ e.appName }}, {{ e.ip }}">{{ e.appName }}</span>
</td>
<td bitCell>
<span title="{{ e.userEmail }}">{{ e.userName }}</span>
</td>
<td bitCell [innerHTML]="e.message"></td>
</tr>
</ng-template>
</bit-table>
<button
#moreBtn
[appApiAction]="morePromise"
type="button"
bitButton
buttonType="primary"
(click)="loadEvents(false)"
[disabled]="loaded && $any(moreBtn).loading"
*ngIf="continuationToken"
>
<i
class="bwi bwi-spinner bwi-spin"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
*ngIf="moreBtn.loading"
></i>
<span>{{ "loadMore" | i18n }}</span>
</button>
</ng-container>

View File

@@ -0,0 +1,172 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { concatMap, Subject, takeUntil } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ExportService } from "@bitwarden/common/abstractions/export.service";
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EventSystemUser } from "@bitwarden/common/enums/event-system-user";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { BaseEventsComponent } from "../../../common/base.events.component";
import { EventService } from "../../../core";
const EVENT_SYSTEM_USER_TO_TRANSLATION: Record<EventSystemUser, string> = {
[EventSystemUser.SCIM]: null, // SCIM acronym not able to be translated so just display SCIM
[EventSystemUser.DomainVerification]: "domainVerification",
};
@Component({
selector: "app-org-events",
templateUrl: "events.component.html",
})
export class EventsComponent extends BaseEventsComponent implements OnInit, OnDestroy {
exportFileName = "org-events";
organizationId: string;
organization: Organization;
private orgUsersUserIdMap = new Map<string, any>();
private destroy$ = new Subject<void>();
constructor(
private apiService: ApiService,
private route: ActivatedRoute,
eventService: EventService,
i18nService: I18nService,
exportService: ExportService,
platformUtilsService: PlatformUtilsService,
private router: Router,
logService: LogService,
private userNamePipe: UserNamePipe,
private organizationService: OrganizationService,
private organizationUserService: OrganizationUserService,
private providerService: ProviderService,
fileDownloadService: FileDownloadService
) {
super(
eventService,
i18nService,
exportService,
platformUtilsService,
logService,
fileDownloadService
);
}
async ngOnInit() {
this.route.params
.pipe(
concatMap(async (params) => {
this.organizationId = params.organizationId;
this.organization = await this.organizationService.get(this.organizationId);
if (this.organization == null || !this.organization.useEvents) {
await this.router.navigate(["/organizations", this.organizationId]);
return;
}
await this.load();
}),
takeUntil(this.destroy$)
)
.subscribe();
}
async load() {
const response = await this.organizationUserService.getAllUsers(this.organizationId);
response.data.forEach((u) => {
const name = this.userNamePipe.transform(u);
this.orgUsersUserIdMap.set(u.userId, { name: name, email: u.email });
});
if (this.organization.providerId != null) {
try {
const provider = await this.providerService.get(this.organization.providerId);
if (
provider != null &&
(await this.providerService.get(this.organization.providerId)).canManageUsers
) {
const providerUsersResponse = await this.apiService.getProviderUsers(
this.organization.providerId
);
providerUsersResponse.data.forEach((u) => {
const name = this.userNamePipe.transform(u);
this.orgUsersUserIdMap.set(u.userId, {
name: `${name} (${this.organization.providerName})`,
email: u.email,
});
});
}
} catch (e) {
this.logService.warning(e);
}
}
await this.loadEvents(true);
this.loaded = true;
}
protected requestEvents(startDate: string, endDate: string, continuationToken: string) {
return this.apiService.getEventsOrganization(
this.organizationId,
startDate,
endDate,
continuationToken
);
}
protected getUserName(r: EventResponse, userId: string) {
if (r.installationId != null) {
return `Installation: ${r.installationId}`;
}
if (userId != null) {
if (this.orgUsersUserIdMap.has(userId)) {
return this.orgUsersUserIdMap.get(userId);
}
if (r.providerId != null && r.providerId === this.organization.providerId) {
return {
name: this.organization.providerName,
};
}
}
if (r.systemUser != null) {
const systemUserI18nKey: string = EVENT_SYSTEM_USER_TO_TRANSLATION[r.systemUser];
if (systemUserI18nKey) {
return {
name: this.i18nService.t(systemUserI18nKey),
};
} else {
return {
name: EventSystemUser[r.systemUser],
};
}
}
if (r.serviceAccountId) {
return {
name: this.i18nService.t("serviceAccount") + " " + this.getShortId(r.serviceAccountId),
};
}
return null;
}
private getShortId(id: string) {
return id?.substring(0, 8);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,92 @@
<form [formGroup]="groupForm" [bitSubmit]="submit">
<bit-dialog [disablePadding]="!loading">
<span bitDialogTitle>
{{ title }}
<span *ngIf="editMode" class="tw-text-sm tw-normal-case tw-text-muted">{{
group?.name
}}</span>
</span>
<div bitDialogContent>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<bit-tab-group *ngIf="!loading" [(selectedIndex)]="tabIndex">
<bit-tab label="{{ 'groupInfo' | i18n }}">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput appAutofocus type="text" formControlName="name" />
<bit-hint>{{ "characterMaximum" | i18n : 100 }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'members' | i18n }}">
<p>{{ "editGroupMembersDesc" | i18n }}</p>
<bit-access-selector
formControlName="members"
[items]="members"
[showMemberRoles]="true"
[permissionMode]="PermissionMode.Hidden"
[columnHeader]="'member' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
></bit-access-selector>
</bit-tab>
<bit-tab label="{{ 'collections' | i18n }}">
<p>{{ "editGroupCollectionsDesc" | i18n }}</p>
<div class="tw-my-3">
<input type="checkbox" formControlName="accessAll" id="accessAll" />
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{
"accessAllCollectionsDesc" | i18n
}}</label>
<p class="tw-my-0 tw-text-muted">{{ "accessAllCollectionsHelp" | i18n }}</p>
</div>
<ng-container *ngIf="!groupForm.value.accessAll">
<bit-access-selector
formControlName="collections"
[items]="collections"
[permissionMode]="PermissionMode.Edit"
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
></bit-access-selector>
</ng-container>
</bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "save" | i18n }}
</button>
<button
bitButton
buttonType="secondary"
type="button"
bitDialogClose
[bit-dialog-close]="ResultType.Canceled"
>
{{ "cancel" | i18n }}
</button>
<button
class="tw-ml-auto"
type="button"
buttonType="danger"
bitIconButton="bwi-trash"
bitFormButton
[bitAction]="delete"
[appA11yTitle]="'delete' | i18n"
></button>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,294 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { catchError, combineLatest, from, map, of, Subject, switchMap, takeUntil } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/admin-console/models/data/collection.data";
import { Collection } from "@bitwarden/common/admin-console/models/domain/collection";
import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/response/collection.response";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../organizations/core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
PermissionMode,
} from "../shared/components/access-selector";
/**
* Indices for the available tabs in the dialog
*/
export enum GroupAddEditTabType {
Info = 0,
Members = 1,
Collections = 2,
}
export interface GroupAddEditDialogParams {
/**
* ID of the organization the group belongs to
*/
organizationId: string;
/**
* Optional ID of the group being modified
*/
groupId?: string;
/**
* Tab to open when the dialog is open.
* Defaults to Group Info
*/
initialTab?: GroupAddEditTabType;
}
export enum GroupAddEditDialogResultType {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
}
/**
* Strongly typed helper to open a groupAddEditDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openGroupAddEditDialog = (
dialogService: DialogService,
config: DialogConfig<GroupAddEditDialogParams>
) => {
return dialogService.open<GroupAddEditDialogResultType, GroupAddEditDialogParams>(
GroupAddEditComponent,
config
);
};
@Component({
selector: "app-group-add-edit",
templateUrl: "group-add-edit.component.html",
})
export class GroupAddEditComponent implements OnInit, OnDestroy {
protected PermissionMode = PermissionMode;
protected ResultType = GroupAddEditDialogResultType;
tabIndex: GroupAddEditTabType;
loading = true;
editMode = false;
title: string;
collections: AccessItemView[] = [];
members: AccessItemView[] = [];
group: GroupView;
groupForm = this.formBuilder.group({
accessAll: [false],
name: ["", [Validators.required, Validators.maxLength(100)]],
externalId: this.formBuilder.control({ value: "", disabled: true }),
members: [[] as AccessItemValue[]],
collections: [[] as AccessItemValue[]],
});
get groupId(): string | undefined {
return this.params.groupId;
}
get organizationId(): string {
return this.params.organizationId;
}
private destroy$ = new Subject<void>();
private get orgCollections$() {
return from(this.apiService.getCollections(this.organizationId)).pipe(
switchMap((response) => {
return from(
this.collectionService.decryptMany(
response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
)
)
);
}),
map((collections) =>
collections.map<AccessItemView>((c) => ({
id: c.id,
type: AccessItemType.Collection,
labelName: c.name,
listName: c.name,
}))
)
);
}
private get orgMembers$() {
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
map((response) =>
response.data.map((m) => ({
id: m.id,
type: AccessItemType.Member,
email: m.email,
role: m.type,
listName: m.name?.length > 0 ? `${m.name} (${m.email})` : m.email,
labelName: m.name || m.email,
status: m.status,
}))
)
);
}
private get groupDetails$() {
if (!this.editMode) {
return of(undefined);
}
return combineLatest([
this.groupService.get(this.organizationId, this.groupId),
this.apiService.getGroupUsers(this.organizationId, this.groupId),
]).pipe(
map(([groupView, users]) => {
groupView.members = users;
return groupView;
}),
catchError((e: unknown) => {
if (e instanceof ErrorResponse) {
this.logService.error(e.message);
} else {
this.logService.error(e.toString());
}
return of(undefined);
})
);
}
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
private apiService: ApiService,
private organizationUserService: OrganizationUserService,
private groupService: GroupService,
private i18nService: I18nService,
private collectionService: CollectionService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private formBuilder: FormBuilder,
private changeDetectorRef: ChangeDetectorRef
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}
ngOnInit() {
this.editMode = this.loading = this.groupId != null;
this.title = this.i18nService.t(this.editMode ? "editGroup" : "newGroup");
combineLatest([this.orgCollections$, this.orgMembers$, this.groupDetails$])
.pipe(takeUntil(this.destroy$))
.subscribe(([collections, members, group]) => {
this.collections = collections;
this.members = members;
this.group = group;
if (this.group != undefined) {
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// collections/members set above, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.groupForm.patchValue({
name: this.group.name,
externalId: this.group.externalId,
accessAll: this.group.accessAll,
members: this.group.members.map((m) => ({
id: m,
type: AccessItemType.Member,
})),
collections: this.group.collections.map((gc) => ({
id: gc.id,
type: AccessItemType.Collection,
permission: convertToPermission(gc),
})),
});
}
this.loading = false;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
this.groupForm.markAllAsTouched();
if (this.groupForm.invalid) {
if (this.tabIndex !== GroupAddEditTabType.Info) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("groupInfo"))
);
}
return;
}
const groupView = new GroupView();
groupView.id = this.groupId;
groupView.organizationId = this.organizationId;
const formValue = this.groupForm.value;
groupView.name = formValue.name;
groupView.accessAll = formValue.accessAll;
groupView.members = formValue.members?.map((m) => m.id) ?? [];
if (!groupView.accessAll) {
groupView.collections = formValue.collections.map((c) => convertToSelectionView(c));
}
await this.groupService.save(groupView);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.editMode ? "editedGroupId" : "createdGroupId", formValue.name)
);
this.dialogRef.close(GroupAddEditDialogResultType.Saved);
};
delete = async () => {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteGroupConfirmation"),
this.group.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning",
false,
"app-group-add-edit .modal-content"
);
if (!confirmed) {
return false;
}
await this.groupService.delete(this.organizationId, this.groupId);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedGroupId", this.group.name)
);
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
};
}

View File

@@ -0,0 +1,128 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div class="container page-content">
<div class="tw-mb-4 tw-flex">
<h1>{{ "groups" | i18n }}</h1>
<div class="tw-ml-auto tw-flex tw-items-center">
<div class="tw-mr-2">
<label class="sr-only">{{ "search" | i18n }}</label>
<div class="tw-flex tw-items-center">
<i class="bwi bwi-search bwi-fw tw-z-20 -tw-mr-7 tw-text-muted" aria-hidden="true"></i>
<input
bitInput
type="search"
placeholder="{{ 'searchGroups' | i18n }}"
class="tw-rounded-l tw-pl-9"
[(ngModel)]="searchText"
/>
</div>
</div>
<button bitButton type="button" buttonType="primary" (click)="add()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newGroup" | i18n }}
</button>
</div>
</div>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="!loading && visibleGroups">
<p *ngIf="!visibleGroups.length">{{ "noGroupsInList" | i18n }}</p>
<bit-table
*ngIf="visibleGroups.length"
infinite-scroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-2"
(change)="toggleAllVisible($event)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "collections" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button type="button" bitMenuItem (click)="deleteAllSelected()">
<span class="tw-text-danger"
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let g of visibleGroups">
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" />
</td>
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
<button bitLink>
{{ g.details.name }}
</button>
</td>
<td bitCell (click)="edit(g, ModalTabType.Collections)" class="tw-cursor-pointer">
<bit-badge-list
*ngIf="!g.details.accessAll"
[items]="g.collectionNames"
[maxItems]="3"
badgeType="secondary"
></bit-badge-list>
<span *ngIf="g.details.accessAll">{{ "all" | i18n }}</span>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button type="button" bitMenuItem (click)="edit(g)">
<i aria-hidden="true" class="bwi bwi-pencil-square"></i> {{ "editInfo" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Members)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "members" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(g, ModalTabType.Collections)">
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
</button>
<button type="button" bitMenuItem (click)="delete(g)">
<span class="tw-text-danger"
><i aria-hidden="true" class="bwi bwi-trash"></i> {{ "delete" | i18n }}</span
>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
<ng-template #addEdit></ng-template>
</div>

View File

@@ -0,0 +1,354 @@
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
from,
lastValueFrom,
map,
Subject,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { first } from "rxjs/operators";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/admin-console/models/data/collection.data";
import { Collection } from "@bitwarden/common/admin-console/models/domain/collection";
import {
CollectionDetailsResponse,
CollectionResponse,
} from "@bitwarden/common/admin-console/models/response/collection.response";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { Utils } from "@bitwarden/common/misc/utils";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { DialogService } from "@bitwarden/components";
import { GroupService, GroupView } from "../../../organizations/core";
import {
GroupAddEditDialogResultType,
GroupAddEditTabType,
openGroupAddEditDialog,
} from "./group-add-edit.component";
type CollectionViewMap = {
[id: string]: CollectionView;
};
type GroupDetailsRow = {
/**
* Group Id (used for searching)
*/
id: string;
/**
* Group name (used for searching)
*/
name: string;
/**
* Details used for displaying group information
*/
details: GroupView;
/**
* True if the group is selected in the table
*/
checked?: boolean;
/**
* A list of collection names the group has access to
*/
collectionNames?: string[];
};
@Component({
selector: "app-org-groups",
templateUrl: "groups.component.html",
})
export class GroupsComponent implements OnInit, OnDestroy {
@ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef;
@ViewChild("usersTemplate", { read: ViewContainerRef, static: true })
usersModalRef: ViewContainerRef;
loading = true;
organizationId: string;
groups: GroupDetailsRow[];
protected didScroll = false;
protected pageSize = 100;
protected ModalTabType = GroupAddEditTabType;
private pagedGroupsCount = 0;
private pagedGroups: GroupDetailsRow[];
private searchedGroups: GroupDetailsRow[];
private _searchText: string;
private destroy$ = new Subject<void>();
private refreshGroups$ = new BehaviorSubject<void>(null);
get searchText() {
return this._searchText;
}
set searchText(value: string) {
this._searchText = value;
// Manually update as we are not using the search pipe in the template
this.updateSearchedGroups();
}
/**
* The list of groups that should be visible in the table.
* This is needed as there are two modes (paging/searching) and
* we need a reference to the currently visible groups for
* the Select All checkbox
*/
get visibleGroups(): GroupDetailsRow[] {
if (this.isPaging()) {
return this.pagedGroups;
}
if (this.isSearching()) {
return this.searchedGroups;
}
return this.groups;
}
constructor(
private apiService: ApiService,
private groupService: GroupService,
private route: ActivatedRoute,
private i18nService: I18nService,
private modalService: ModalService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private searchService: SearchService,
private logService: LogService,
private collectionService: CollectionService,
private searchPipe: SearchPipe
) {}
async ngOnInit() {
this.route.params
.pipe(
tap((params) => (this.organizationId = params.organizationId)),
switchMap(() =>
combineLatest([
// collectionMap
from(this.apiService.getCollections(this.organizationId)).pipe(
concatMap((response) => this.toCollectionMap(response))
),
// groups
this.refreshGroups$.pipe(
switchMap(() => this.groupService.getAll(this.organizationId))
),
])
),
map(([collectionMap, groups]) => {
return groups
.sort(Utils.getSortFunction(this.i18nService, "name"))
.map<GroupDetailsRow>((g) => ({
id: g.id,
name: g.name,
details: g,
checked: false,
collectionNames: g.collections
.map((c) => collectionMap[c.id]?.name)
.sort(this.i18nService.collator?.compare),
}));
}),
takeUntil(this.destroy$)
)
.subscribe((groups) => {
this.groups = groups;
this.resetPaging();
this.updateSearchedGroups();
this.loading = false;
});
this.route.queryParams
.pipe(
first(),
concatMap(async (qParams) => {
this.searchText = qParams.search;
}),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
loadMore() {
if (!this.groups || this.groups.length <= this.pageSize) {
return;
}
const pagedLength = this.pagedGroups.length;
let pagedSize = this.pageSize;
if (pagedLength === 0 && this.pagedGroupsCount > this.pageSize) {
pagedSize = this.pagedGroupsCount;
}
if (this.groups.length > pagedLength) {
this.pagedGroups = this.pagedGroups.concat(
this.groups.slice(pagedLength, pagedLength + pagedSize)
);
}
this.pagedGroupsCount = this.pagedGroups.length;
this.didScroll = this.pagedGroups.length > this.pageSize;
}
async edit(
group: GroupDetailsRow,
startingTabIndex: GroupAddEditTabType = GroupAddEditTabType.Info
) {
const dialogRef = openGroupAddEditDialog(this.dialogService, {
data: {
initialTab: startingTabIndex,
organizationId: this.organizationId,
groupId: group != null ? group.details.id : null,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result == GroupAddEditDialogResultType.Saved) {
this.refreshGroups$.next();
} else if (result == GroupAddEditDialogResultType.Deleted) {
this.removeGroup(group.details.id);
}
}
add() {
this.edit(null);
}
async delete(groupRow: GroupDetailsRow) {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteGroupConfirmation"),
groupRow.details.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.groupService.delete(this.organizationId, groupRow.details.id);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedGroupId", groupRow.details.name)
);
this.removeGroup(groupRow.details.id);
} catch (e) {
this.logService.error(e);
}
}
async deleteAllSelected() {
const groupsToDelete = this.groups.filter((g) => g.checked);
if (groupsToDelete.length == 0) {
return;
}
const deleteMessage = groupsToDelete.map((g) => g.details.name).join(", ");
const confirmed = await this.platformUtilsService.showDialog(
deleteMessage,
this.i18nService.t("deleteMultipleGroupsConfirmation", groupsToDelete.length.toString()),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed) {
return false;
}
try {
await this.groupService.deleteMany(
this.organizationId,
groupsToDelete.map((g) => g.details.id)
);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedManyGroups", groupsToDelete.length.toString())
);
groupsToDelete.forEach((g) => this.removeGroup(g.details.id));
} catch (e) {
this.logService.error(e);
}
}
resetPaging() {
this.pagedGroups = [];
this.loadMore();
}
isSearching() {
return this.searchService.isSearchable(this.searchText);
}
check(groupRow: GroupDetailsRow) {
groupRow.checked = !groupRow.checked;
}
toggleAllVisible(event: Event) {
this.visibleGroups.forEach((g) => (g.checked = (event.target as HTMLInputElement).checked));
}
isPaging() {
const searching = this.isSearching();
if (searching && this.didScroll) {
this.resetPaging();
}
return !searching && this.groups && this.groups.length > this.pageSize;
}
private removeGroup(id: string) {
const index = this.groups.findIndex((g) => g.details.id === id);
if (index > -1) {
this.groups.splice(index, 1);
this.resetPaging();
this.updateSearchedGroups();
}
}
private async toCollectionMap(response: ListResponse<CollectionResponse>) {
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
// Convert to an object using collection Ids as keys for faster name lookups
const collectionMap: CollectionViewMap = {};
decryptedCollections.forEach((c) => (collectionMap[c.id] = c));
return collectionMap;
}
private updateSearchedGroups() {
if (this.searchService.isSearchable(this.searchText)) {
// Making use of the pipe in the component as we need know which groups where filtered
this.searchedGroups = this.searchPipe.transform(
this.groups,
this.searchText,
(group) => group.details.name,
(group) => group.details.id
);
}
}
}

View File

@@ -0,0 +1,38 @@
<div class="container page-content">
<div class="row">
<div class="col-3">
<div class="card" *ngIf="organization">
<div class="card-header">{{ "manage" | i18n }}</div>
<div class="list-group list-group-flush">
<a
routerLink="members"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageUsers"
>
{{ "members" | i18n }}
</a>
<a
routerLink="collections"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canViewAllCollections || organization.canViewAssignedCollections"
>
{{ "collections" | i18n }}
</a>
<a
routerLink="groups"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageGroups"
>
{{ "groups" | i18n }}
</a>
</div>
</div>
</div>
<div class="col-9">
<router-outlet></router-outlet>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Component({
selector: "app-org-manage",
templateUrl: "manage.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class ManageComponent implements OnInit {
organization: Organization;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);
});
}
}

View File

@@ -0,0 +1,12 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core";
import { EntityUsersComponent } from "../../../admin-console/organizations/manage/entity-users.component";
import { SharedModule } from "../../../shared";
@NgModule({
imports: [SharedModule, ScrollingModule],
declarations: [EntityUsersComponent],
exports: [EntityUsersComponent],
})
export class OrganizationManageModule {}

View File

@@ -0,0 +1,52 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="confirmUserTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="confirmUserTitle">
{{ "confirmUser" | i18n }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
{{ "fingerprintEnsureIntegrityVerify" | i18n }}
<a href="https://bitwarden.com/help/fingerprint-phrase/" target="_blank" rel="noopener">
{{ "learnMore" | i18n }}</a
>
</p>
<p>
<code>{{ fingerprint }}</code>
</p>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="dontAskAgain"
name="DontAskAgain"
[(ngModel)]="dontAskAgain"
/>
<label class="form-check-label" for="dontAskAgain">
{{ "dontAskFingerprintAgain" | i18n }}
</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "confirm" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,56 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
@Component({
selector: "app-user-confirm",
templateUrl: "user-confirm.component.html",
})
export class UserConfirmComponent implements OnInit {
@Input() name: string;
@Input() userId: string;
@Input() publicKey: Uint8Array;
@Output() onConfirmedUser = new EventEmitter();
dontAskAgain = false;
loading = true;
fingerprint: string;
formPromise: Promise<any>;
constructor(
private cryptoService: CryptoService,
private logService: LogService,
private stateService: StateService
) {}
async ngOnInit() {
try {
if (this.publicKey != null) {
const fingerprint = await this.cryptoService.getFingerprint(
this.userId,
this.publicKey.buffer
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
}
} catch (e) {
this.logService.error(e);
}
this.loading = false;
}
async submit() {
if (this.loading) {
return;
}
if (this.dontAskAgain) {
await this.stateService.setAutoConfirmFingerprints(true);
}
this.onConfirmedUser.emit();
}
}

View File

@@ -0,0 +1,2 @@
export * from "./member-dialog.component";
export * from "./member-dialog.module";

View File

@@ -0,0 +1,378 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [disablePadding]="!loading">
<span bitDialogTitle>
{{ title }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading && params.name">{{
params.name
}}</span>
<span bitBadge badgeType="secondary" *ngIf="isRevoked">{{ "revoked" | i18n }}</span>
</span>
<div bitDialogContent>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-tab-group *ngIf="!loading" [(selectedIndex)]="tabIndex">
<bit-tab [label]="'role' | i18n">
<ng-container *ngIf="!editMode">
<p>{{ "inviteUserDesc" | i18n }}</p>
<bit-form-field>
<bit-label>{{ "email" | i18n }}</bit-label>
<input id="emails" type="text" appAutoFocus bitInput formControlName="emails" />
<bit-hint>{{ "inviteMultipleEmailDesc" | i18n : "20" }}</bit-hint>
</bit-form-field>
</ng-container>
<fieldset role="radiogroup" aria-labelledby="roleGroupLabel" class="tw-mb-6">
<legend
id="roleGroupLabel"
class="tw-mb-2 tw-block tw-text-base tw-font-semibold tw-text-main"
>
{{ "memberRole" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</legend>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeUser"
[value]="organizationUserType.User"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeUser">
{{ "user" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "userDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeManager"
[value]="organizationUserType.Manager"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeManager">
{{ "manager" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "managerDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeAdmin"
[value]="organizationUserType.Admin"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeAdmin">
{{ "admin" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "adminDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-mb-2 tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeOwner"
[value]="organizationUserType.Owner"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
formControlName="type"
name="type"
/>
<label class="tw-m-0" for="userTypeOwner">
{{ "owner" | i18n }}
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "ownerDesc" | i18n }}
</div>
</label>
</div>
<div class="tw-flex tw-items-baseline">
<input
type="radio"
id="userTypeCustom"
[value]="organizationUserType.Custom"
formControlName="type"
name="type"
class="tw-relative tw-bottom-[-1px] tw-mr-2"
[attr.disabled]="!canUseCustomPermissions || null"
/>
<label class="tw-m-0" for="userTypeCustom">
{{ "custom" | i18n }}
<ng-container *ngIf="!canUseCustomPermissions; else enterprise">
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "customDescNonEnterpriseStart" | i18n
}}<a href="https://bitwarden.com/contact/" target="_blank">{{
"customDescNonEnterpriseLink" | i18n
}}</a
>{{ "customDescNonEnterpriseEnd" | i18n }}
</div>
</ng-container>
<ng-template #enterprise>
<div class="text-base tw-block tw-font-normal tw-text-muted">
{{ "customDesc" | i18n }}
</div>
</ng-template>
</label>
</div>
</fieldset>
<ng-container *ngIf="customUserTypeSelected">
<h3 class="mt-4 d-flex tw-font-semibold">
{{ "permissions" | i18n }}
</h3>
<div class="row" [formGroup]="permissionsGroup">
<div class="col-6">
<div class="mb-3">
<label class="tw-font-semibold">{{ "managerPermissions" | i18n }}</label>
<hr class="tw-mt-0 tw-mb-2 tw-mr-2" />
<app-nested-checkbox
parentId="manageAssignedCollections"
[checkboxes]="permissionsGroup.controls.manageAssignedCollectionsGroup"
>
</app-nested-checkbox>
</div>
</div>
<div class="col-6">
<div class="mb-3">
<label class="tw-font-semibold">{{ "adminPermissions" | i18n }}</label>
<hr class="tw-mt-0 tw-mb-2 tw-mr-2" />
<div>
<input
type="checkbox"
name="accessEventLogs"
id="accessEventLogs"
formControlName="accessEventLogs"
/>
<label class="!tw-font-normal" for="accessEventLogs">
{{ "accessEventLogs" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="accessImportExport"
id="accessImportExport"
formControlName="accessImportExport"
/>
<label class="!tw-font-normal" for="accessImportExport">
{{ "accessImportExport" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="accessReports"
id="accessReports"
formControlName="accessReports"
/>
<label class="!tw-font-normal" for="accessReports">
{{ "accessReports" | i18n }}
</label>
</div>
<app-nested-checkbox
parentId="manageAllCollections"
[checkboxes]="permissionsGroup.controls.manageAllCollectionsGroup"
>
</app-nested-checkbox>
<div>
<input
type="checkbox"
name="manageGroups"
id="manageGroups"
formControlName="manageGroups"
/>
<label class="!tw-font-normal" for="manageGroups">
{{ "manageGroups" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageSso"
id="manageSso"
formControlName="manageSso"
/>
<label class="!tw-font-normal" for="manageSso">
{{ "manageSso" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="managePolicies"
id="managePolicies"
formControlName="managePolicies"
/>
<label class="!tw-font-normal" for="managePolicies">
{{ "managePolicies" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageUsers"
id="manageUsers"
formControlName="manageUsers"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageUsers">
{{ "manageUsers" | i18n }}
</label>
</div>
<div>
<input
type="checkbox"
name="manageResetPassword"
id="manageResetPassword"
formControlName="manageResetPassword"
(change)="handleDependentPermissions()"
/>
<label class="!tw-font-normal" for="manageResetPassword">
{{ "manageResetPassword" | i18n }}
</label>
</div>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="canUseSecretsManager">
<h3 class="mt-4">
{{ "secretsManagerBeta" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/manage-your-organization/#access-to-secrets-manager"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</h3>
<p class="tw-text-muted">{{ "secretsManagerBetaDesc" | i18n }}</p>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessSecretsManager" />
<bit-label>
{{ "userAccessSecretsManager" | i18n }}
</bit-label>
</bit-form-control>
</ng-container>
<bit-form-field>
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
</bit-tab>
<bit-tab *ngIf="organization.useGroups" [label]="'groups' | i18n">
<div class="tw-mb-6">
{{ "groupAccessUserDesc" | i18n }}
</div>
<bit-access-selector
formControlName="groups"
[items]="groupAccessItems"
[columnHeader]="'groups' | i18n"
[selectorLabelText]="'selectGroups' | i18n"
[emptySelectionText]="'noGroupsAdded' | i18n"
></bit-access-selector>
</bit-tab>
<bit-tab [label]="'collections' | i18n">
<div *ngIf="organization.useGroups" class="tw-mb-6">
{{ "userPermissionOverrideHelper" | i18n }}
</div>
<div class="tw-mb-6">
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="accessAllCollections" />
<bit-label>
{{ "accessAllCollectionsDesc" | i18n }}
<a
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
href="https://bitwarden.com/help/user-types-access-control/#access-control"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<bit-hint>{{ "accessAllCollectionsHelp" | i18n }}</bit-hint>
</bit-form-control>
</div>
<bit-access-selector
*ngIf="!accessAllCollections"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[showGroupColumn]="organization.useGroups"
[items]="collectionAccessItems"
[columnHeader]="'collection' | i18n"
[selectorLabelText]="'selectCollections' | i18n"
[emptySelectionText]="'noCollectionsAdded' | i18n"
></bit-access-selector
></bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
(click)="cancel()"
[disabled]="loading"
>
{{ "cancel" | i18n }}
</button>
<div class="tw-ml-auto">
<button
*ngIf="editMode && isRevoked"
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitAction]="restore"
[disabled]="loading"
>
{{ "restoreAccess" | i18n }}
</button>
<button
*ngIf="editMode && !isRevoked"
type="button"
bitButton
bitFormButton
buttonType="secondary"
[bitAction]="revoke"
[disabled]="loading"
>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="editMode"
type="button"
bitIconButton="bwi-trash"
buttonType="danger"
bitFormButton
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
[disabled]="loading"
></button>
</div>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,517 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { DialogService } from "@bitwarden/components";
import { flagEnabled } from "../../../../../../utils/flags";
import {
CollectionAccessSelectionView,
CollectionAdminService,
GroupService,
GroupView,
OrganizationUserAdminView,
UserAdminService,
} from "../../../../../organizations/core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
PermissionMode,
} from "../../../shared/components/access-selector";
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
export enum MemberDialogTab {
Role = 0,
Groups = 1,
Collections = 2,
}
export interface MemberDialogParams {
name: string;
organizationId: string;
organizationUserId: string;
usesKeyConnector: boolean;
initialTab?: MemberDialogTab;
}
export enum MemberDialogResult {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
Revoked = "revoked",
Restored = "restored",
}
@Component({
selector: "app-member-dialog",
templateUrl: "member-dialog.component.html",
})
export class MemberDialogComponent implements OnInit, OnDestroy {
loading = true;
editMode = false;
isRevoked = false;
title: string;
access: "all" | "selected" = "selected";
collections: CollectionView[] = [];
organizationUserType = OrganizationUserType;
canUseCustomPermissions: boolean;
PermissionMode = PermissionMode;
canUseSecretsManager: boolean;
protected organization: Organization;
protected collectionAccessItems: AccessItemView[] = [];
protected groupAccessItems: AccessItemView[] = [];
protected tabIndex: MemberDialogTab;
protected formGroup = this.formBuilder.group({
emails: ["", [Validators.required, commaSeparatedEmails]],
type: OrganizationUserType.User,
externalId: this.formBuilder.control({ value: "", disabled: true }),
accessAllCollections: false,
accessSecretsManager: false,
access: [[] as AccessItemValue[]],
groups: [[] as AccessItemValue[]],
});
protected permissionsGroup = this.formBuilder.group({
manageAssignedCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
manageAssignedCollections: false,
editAssignedCollections: false,
deleteAssignedCollections: false,
}),
manageAllCollectionsGroup: this.formBuilder.group<Record<string, boolean>>({
manageAllCollections: false,
createNewCollections: false,
editAnyCollection: false,
deleteAnyCollection: false,
}),
accessEventLogs: false,
accessImportExport: false,
accessReports: false,
manageGroups: false,
manageSso: false,
managePolicies: false,
manageUsers: false,
manageResetPassword: false,
});
private destroy$ = new Subject<void>();
get customUserTypeSelected(): boolean {
return this.formGroup.value.type === OrganizationUserType.Custom;
}
get accessAllCollections(): boolean {
return this.formGroup.value.accessAllCollections;
}
constructor(
@Inject(DIALOG_DATA) protected params: MemberDialogParams,
private dialogRef: DialogRef<MemberDialogResult>,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private organizationService: OrganizationService,
private formBuilder: FormBuilder,
// TODO: We should really look into consolidating naming conventions for these services
private collectionAdminService: CollectionAdminService,
private groupService: GroupService,
private userService: UserAdminService,
private organizationUserService: OrganizationUserService
) {}
async ngOnInit() {
this.editMode = this.params.organizationUserId != null;
this.tabIndex = this.params.initialTab ?? MemberDialogTab.Role;
this.title = this.i18nService.t(this.editMode ? "editMember" : "inviteMember");
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 })
);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(this.params.organizationId);
})
);
combineLatest({
organization: organization$,
collections: this.collectionAdminService.getAll(this.params.organizationId),
userDetails: this.params.organizationUserId
? this.userService.get(this.params.organizationId, this.params.organizationUserId)
: of(null),
groups: groups$,
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, userDetails, groups }) => {
this.organization = organization;
this.canUseCustomPermissions = organization.useCustomPermissions;
this.canUseSecretsManager = organization.useSecretsManager && flagEnabled("secretsManager");
this.collectionAccessItems = [].concat(
collections.map((c) => mapCollectionToAccessItemView(c))
);
this.groupAccessItems = [].concat(
groups.map<AccessItemView>((g) => mapGroupToAccessItemView(g))
);
if (this.params.organizationUserId) {
if (!userDetails) {
throw new Error("Could not find user to edit.");
}
this.isRevoked = userDetails.status === OrganizationUserStatusType.Revoked;
const assignedCollectionsPermissions = {
editAssignedCollections: userDetails.permissions.editAssignedCollections,
deleteAssignedCollections: userDetails.permissions.deleteAssignedCollections,
manageAssignedCollections:
userDetails.permissions.editAssignedCollections &&
userDetails.permissions.deleteAssignedCollections,
};
const allCollectionsPermissions = {
createNewCollections: userDetails.permissions.createNewCollections,
editAnyCollection: userDetails.permissions.editAnyCollection,
deleteAnyCollection: userDetails.permissions.deleteAnyCollection,
manageAllCollections:
userDetails.permissions.createNewCollections &&
userDetails.permissions.editAnyCollection &&
userDetails.permissions.deleteAnyCollection,
};
if (userDetails.type === OrganizationUserType.Custom) {
this.permissionsGroup.patchValue({
accessEventLogs: userDetails.permissions.accessEventLogs,
accessImportExport: userDetails.permissions.accessImportExport,
accessReports: userDetails.permissions.accessReports,
manageGroups: userDetails.permissions.manageGroups,
manageSso: userDetails.permissions.manageSso,
managePolicies: userDetails.permissions.managePolicies,
manageUsers: userDetails.permissions.manageUsers,
manageResetPassword: userDetails.permissions.manageResetPassword,
manageAssignedCollectionsGroup: assignedCollectionsPermissions,
manageAllCollectionsGroup: allCollectionsPermissions,
});
}
const collectionsFromGroups = groups
.filter((group) => userDetails.groups.includes(group.id))
.flatMap((group) =>
group.collections.map((accessSelection) => {
const collection = collections.find((c) => c.id === accessSelection.id);
return { group, collection, accessSelection };
})
);
this.collectionAccessItems = this.collectionAccessItems.concat(
collectionsFromGroups.map(({ collection, accessSelection, group }) =>
mapCollectionToAccessItemView(collection, accessSelection, group)
)
);
const accessSelections = mapToAccessSelections(userDetails);
const groupAccessSelections = mapToGroupAccessSelections(userDetails.groups);
this.formGroup.removeControl("emails");
this.formGroup.patchValue({
type: userDetails.type,
externalId: userDetails.externalId,
accessAllCollections: userDetails.accessAll,
access: accessSelections,
accessSecretsManager: userDetails.accessSecretsManager,
groups: groupAccessSelections,
});
}
this.loading = false;
});
}
check(c: CollectionView, select?: boolean) {
(c as any).checked = select == null ? !(c as any).checked : select;
if (!(c as any).checked) {
c.readOnly = false;
}
}
selectAll(select: boolean) {
this.collections.forEach((c) => this.check(c, select));
}
setRequestPermissions(p: PermissionsApi, clearPermissions: boolean): PermissionsApi {
if (clearPermissions) {
return new PermissionsApi();
}
const partialPermissions: Partial<PermissionsApi> = {
accessEventLogs: this.permissionsGroup.value.accessEventLogs,
accessImportExport: this.permissionsGroup.value.accessImportExport,
accessReports: this.permissionsGroup.value.accessReports,
manageGroups: this.permissionsGroup.value.manageGroups,
manageSso: this.permissionsGroup.value.manageSso,
managePolicies: this.permissionsGroup.value.managePolicies,
manageUsers: this.permissionsGroup.value.manageUsers,
manageResetPassword: this.permissionsGroup.value.manageResetPassword,
createNewCollections:
this.permissionsGroup.value.manageAllCollectionsGroup.createNewCollections,
editAnyCollection: this.permissionsGroup.value.manageAllCollectionsGroup.editAnyCollection,
deleteAnyCollection:
this.permissionsGroup.value.manageAllCollectionsGroup.deleteAnyCollection,
editAssignedCollections:
this.permissionsGroup.value.manageAssignedCollectionsGroup.editAssignedCollections,
deleteAssignedCollections:
this.permissionsGroup.value.manageAssignedCollectionsGroup.deleteAssignedCollections,
};
return Object.assign(p, partialPermissions);
}
handleDependentPermissions() {
// Manage Password Reset must have Manage Users enabled
if (
this.permissionsGroup.value.manageResetPassword &&
!this.permissionsGroup.value.manageUsers
) {
this.permissionsGroup.value.manageUsers = true;
(document.getElementById("manageUsers") as HTMLInputElement).checked = true;
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("resetPasswordManageUsers")
);
}
}
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
if (this.tabIndex !== MemberDialogTab.Role) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("role"))
);
}
return;
}
if (!this.canUseCustomPermissions && this.customUserTypeSelected) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("customNonEnterpriseError")
);
return;
}
const userView = new OrganizationUserAdminView();
userView.id = this.params.organizationUserId;
userView.organizationId = this.params.organizationId;
userView.accessAll = this.accessAllCollections;
userView.type = this.formGroup.value.type;
userView.permissions = this.setRequestPermissions(
userView.permissions ?? new PermissionsApi(),
userView.type !== OrganizationUserType.Custom
);
userView.collections = this.formGroup.value.access
.filter((v) => v.type === AccessItemType.Collection)
.map(convertToSelectionView);
userView.groups = this.formGroup.value.groups.map((m) => m.id);
userView.accessSecretsManager = this.formGroup.value.accessSecretsManager;
if (this.editMode) {
await this.userService.save(userView);
} else {
userView.id = this.params.organizationUserId;
const emails = [...new Set(this.formGroup.value.emails.trim().split(/\s*,\s*/))];
if (emails.length > 20) {
this.formGroup.controls.emails.setErrors({
tooManyEmails: { message: this.i18nService.t("tooManyEmails", 20) },
});
return;
}
await this.userService.invite(emails, userView);
}
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(this.editMode ? "editedUserId" : "invitedUsers", this.params.name)
);
this.close(MemberDialogResult.Saved);
};
delete = async () => {
if (!this.editMode) {
return;
}
const message = this.params.usesKeyConnector
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t(message),
this.i18nService.t("removeUserIdAccess", this.params.name),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning",
false,
"app-user-add-edit .modal-content"
);
if (!confirmed) {
return false;
}
await this.organizationUserService.deleteOrganizationUser(
this.params.organizationId,
this.params.organizationUserId
);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("removedUserId", this.params.name)
);
this.close(MemberDialogResult.Deleted);
};
revoke = async () => {
if (!this.editMode) {
return;
}
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("revokeUserConfirmation"),
this.i18nService.t("revokeUserId", this.params.name),
this.i18nService.t("revokeAccess"),
this.i18nService.t("cancel"),
"warning",
false,
"app-user-add-edit .modal-content"
);
if (!confirmed) {
return false;
}
await this.organizationUserService.revokeOrganizationUser(
this.params.organizationId,
this.params.organizationUserId
);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("revokedUserId", this.params.name)
);
this.isRevoked = true;
this.close(MemberDialogResult.Revoked);
};
restore = async () => {
if (!this.editMode) {
return;
}
await this.organizationUserService.restoreOrganizationUser(
this.params.organizationId,
this.params.organizationUserId
);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("restoredUserId", this.params.name)
);
this.isRevoked = false;
this.close(MemberDialogResult.Restored);
};
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected async cancel() {
this.close(MemberDialogResult.Canceled);
}
private close(result: MemberDialogResult) {
this.dialogRef.close(result);
}
}
function mapCollectionToAccessItemView(
collection: CollectionView,
accessSelection?: CollectionAccessSelectionView,
group?: GroupView
): AccessItemView {
return {
type: AccessItemType.Collection,
id: group ? `${collection.id}-${group.id}` : collection.id,
labelName: collection.name,
listName: collection.name,
readonly: group !== undefined,
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
viaGroupName: group?.name,
};
}
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
type: AccessItemType.Group,
id: group.id,
labelName: group.name,
listName: group.name,
};
}
function mapToAccessSelections(user: OrganizationUserAdminView): AccessItemValue[] {
if (user == undefined) {
return [];
}
return [].concat(
user.collections.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Collection,
permission: convertToPermission(selection),
}))
);
}
function mapToGroupAccessSelections(groups: string[]): AccessItemValue[] {
if (groups == undefined) {
return [];
}
return [].concat(
groups.map((groupId) => ({
id: groupId,
type: AccessItemType.Group,
}))
);
}
/**
* Strongly typed helper to open a UserDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openUserAddEditDialog(
dialogService: DialogService,
config: DialogConfig<MemberDialogParams>
) {
return dialogService.open<MemberDialogResult, MemberDialogParams>(MemberDialogComponent, config);
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { RadioButtonModule } from "@bitwarden/components";
import { SharedOrganizationModule } from "../../../../../organizations/shared";
import { MemberDialogComponent } from "./member-dialog.component";
import { NestedCheckboxComponent } from "./nested-checkbox.component";
@NgModule({
declarations: [MemberDialogComponent, NestedCheckboxComponent],
imports: [SharedOrganizationModule, RadioButtonModule],
exports: [MemberDialogComponent],
})
export class UserDialogModule {}

View File

@@ -0,0 +1,29 @@
<div [formGroup]="checkboxes">
<input
type="checkbox"
[name]="pascalize(parentId)"
[id]="parentId"
[formControlName]="parentId"
[indeterminate]="parentIndeterminate"
/>
<label class="!tw-font-normal" [for]="parentId">
{{ parentId | i18n }}
</label>
<div class="tw-ml-6">
<ng-container *ngFor="let c of checkboxes.controls | keyvalue; trackBy: key">
<div class="" *ngIf="c.key != parentId">
<input
class=""
type="checkbox"
[name]="pascalize(c.key)"
[id]="c.key"
[formControl]="c.value"
(change)="onChildCheck()"
/>
<label class="!tw-font-normal" [for]="c.key">
{{ c.key | i18n }}
</label>
</div>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,62 @@
import { KeyValue } from "@angular/common";
import { Component, EventEmitter, Input, Output, OnInit, OnDestroy } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { Utils } from "@bitwarden/common/misc/utils";
@Component({
selector: "app-nested-checkbox",
templateUrl: "nested-checkbox.component.html",
})
export class NestedCheckboxComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
@Input() parentId: string;
@Input() checkboxes: FormGroup<Record<string, FormControl<boolean>>>;
@Output() onSavedUser = new EventEmitter();
@Output() onDeletedUser = new EventEmitter();
get parentIndeterminate() {
return (
this.children.some(([key, control]) => control.value == true) &&
!this.children.every(([key, control]) => control.value == true)
);
}
ngOnInit(): void {
this.checkboxes.controls[this.parentId].valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {
Object.values(this.checkboxes.controls).forEach((control) =>
control.setValue(value, { emitEvent: false })
);
});
}
private get parentCheckbox() {
return this.checkboxes.controls[this.parentId];
}
get children() {
return Object.entries(this.checkboxes.controls).filter(([key, value]) => key != this.parentId);
}
protected onChildCheck() {
const parentChecked = this.children.every(([key, value]) => value.value == true);
this.parentCheckbox.setValue(parentChecked, { emitEvent: false });
}
protected key(index: number, item: KeyValue<string, FormControl<boolean>>) {
return item.key;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
pascalize(s: string) {
return Utils.camelToPascalCase(s);
}
}

View File

@@ -0,0 +1,46 @@
import { FormControl } from "@angular/forms";
import { commaSeparatedEmails } from "./comma-separated-emails.validator";
describe("commaSeparatedEmails", () => {
it("should return no error when input is valid", () => {
const input = createControl(null);
input.setValue("user@bitwarden.com");
const errors = commaSeparatedEmails(input);
expect(errors).toBe(null);
});
it("should return no error when a single valid email is provided", () => {
const input = createControl("user@bitwarden.com");
const errors = commaSeparatedEmails(input);
expect(errors).toBe(null);
});
it("should return no error when input has valid emails separated by commas", () => {
const input = createControl("user@bitwarden.com, user1@bitwarden.com, user@bitwarden.com");
const errors = commaSeparatedEmails(input);
expect(errors).toBe(null);
});
it("should return error when input is invalid", () => {
const input = createControl("lksjflks");
const errors = commaSeparatedEmails(input);
expect(errors).not.toBe(null);
});
it("should return error when input contains invalid emails", () => {
const input = createControl("user@bitwarden.com, nonsfonwoei, user1@bitwarden.com");
const errors = commaSeparatedEmails(input);
expect(errors).not.toBe(null);
});
});
function createControl(input: string) {
return new FormControl(input);
}

View File

@@ -0,0 +1,17 @@
import { AbstractControl, ValidationErrors, Validators } from "@angular/forms";
function validateEmails(emails: string) {
return (
emails
.split(",")
.map((email) => Validators.email(<AbstractControl>{ value: email.trim() }))
.find((_) => _ !== null) === undefined
);
}
export function commaSeparatedEmails(control: AbstractControl): ValidationErrors | null {
if (control.value === "" || !control.value || validateEmails(control.value)) {
return null;
}
return { multipleEmails: { message: "multipleInputEmails" } };
}

View File

@@ -0,0 +1,101 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="resetPasswordTitle">
<div class="modal-dialog" role="document">
<form class="modal-content" #form (ngSubmit)="submit()" [appApiAction]="formPromise">
<div class="modal-header">
<h1 class="modal-title" id="resetPasswordTitle">
{{ "resetPassword" | i18n }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="warning"
>{{ "resetPasswordLoggedOutWarning" | i18n : loggedOutWarningName }}
</app-callout>
<app-callout
type="info"
[enforcedPolicyOptions]="enforcedPolicyOptions"
enforcedPolicyMessage="{{ 'resetPasswordMasterPasswordPolicyInEffect' | i18n }}"
*ngIf="enforcedPolicyOptions"
>
</app-callout>
<div class="row">
<div class="col form-group">
<div class="d-flex">
<label for="newPassword">{{ "newPassword" | i18n }}</label>
<div class="ml-auto d-flex">
<a
href="#"
class="d-block mr-2 bwi-icon-above-input"
appStopClick
appA11yTitle="{{ 'generatePassword' | i18n }}"
(click)="generatePassword()"
>
<i class="bwi bwi-lg bwi-fw bwi-refresh" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="input-group mb-1">
<input
id="newPassword"
class="form-control text-monospace"
appAutofocus
type="{{ showPassword ? 'text' : 'password' }}"
name="NewPassword"
[(ngModel)]="newPassword"
required
appInputVerbatim
autocomplete="new-password"
/>
<div class="input-group-append">
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
(click)="togglePassword()"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
<button
type="button"
class="btn btn-outline-secondary"
appA11yTitle="{{ 'copyPassword' | i18n }}"
(click)="copy(newPassword)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</div>
</div>
<app-password-strength
[password]="newPassword"
[email]="email"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,217 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import zxcvbn from "zxcvbn";
import { PasswordStrengthComponent } from "@bitwarden/angular/shared/components/password-strength/password-strength.component";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordRequest } from "@bitwarden/common/abstractions/organization-user/requests";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { Utils } from "@bitwarden/common/misc/utils";
import { EncString } from "@bitwarden/common/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
@Component({
selector: "app-reset-password",
templateUrl: "reset-password.component.html",
})
export class ResetPasswordComponent implements OnInit, OnDestroy {
@Input() name: string;
@Input() email: string;
@Input() id: string;
@Input() organizationId: string;
@Output() onPasswordReset = new EventEmitter();
@ViewChild(PasswordStrengthComponent) passwordStrengthComponent: PasswordStrengthComponent;
enforcedPolicyOptions: MasterPasswordPolicyOptions;
newPassword: string = null;
showPassword = false;
passwordStrengthResult: zxcvbn.ZXCVBNResult;
formPromise: Promise<any>;
private destroy$ = new Subject<void>();
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private policyService: PolicyService,
private cryptoService: CryptoService,
private logService: LogService,
private organizationUserService: OrganizationUserService
) {}
async ngOnInit() {
this.policyService
.masterPasswordPolicyOptions$()
.pipe(takeUntil(this.destroy$))
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions = enforcedPasswordPolicyOptions)
);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get loggedOutWarningName() {
return this.name != null ? this.name : this.i18nService.t("thisUser");
}
async generatePassword() {
const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {};
this.newPassword = await this.passwordGenerationService.generatePassword(options);
this.passwordStrengthComponent.updatePasswordStrength(this.newPassword);
}
togglePassword() {
this.showPassword = !this.showPassword;
document.getElementById("newPassword").focus();
}
copy(value: string) {
if (value == null) {
return;
}
this.platformUtilsService.copyToClipboard(value, { window: window });
this.platformUtilsService.showToast(
"info",
null,
this.i18nService.t("valueCopied", this.i18nService.t("password"))
);
}
async submit() {
// Validation
if (this.newPassword == null || this.newPassword === "") {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPasswordRequired")
);
return false;
}
if (this.newPassword.length < Utils.minimumPasswordLength) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPasswordMinlength", Utils.minimumPasswordLength)
);
return false;
}
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score,
this.newPassword,
this.enforcedPolicyOptions
)
) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPasswordPolicyRequirementsNotMet")
);
return;
}
if (this.passwordStrengthResult.score < 3) {
const result = await this.platformUtilsService.showDialog(
this.i18nService.t("weakMasterPasswordDesc"),
this.i18nService.t("weakMasterPassword"),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!result) {
return false;
}
}
// Get user Information (kdf type, kdf iterations, resetPasswordKey, private key) and change password
try {
this.formPromise = this.organizationUserService
.getOrganizationUserResetPasswordDetails(this.organizationId, this.id)
.then(async (response) => {
if (response == null) {
throw new Error(this.i18nService.t("resetPasswordDetailsError"));
}
const kdfType = response.kdf;
const kdfIterations = response.kdfIterations;
const kdfMemory = response.kdfMemory;
const kdfParallelism = response.kdfParallelism;
const resetPasswordKey = response.resetPasswordKey;
const encryptedPrivateKey = response.encryptedPrivateKey;
// Decrypt Organization's encrypted Private Key with org key
const orgSymKey = await this.cryptoService.getOrgKey(this.organizationId);
const decPrivateKey = await this.cryptoService.decryptToBytes(
new EncString(encryptedPrivateKey),
orgSymKey
);
// Decrypt User's Reset Password Key to get EncKey
const decValue = await this.cryptoService.rsaDecrypt(resetPasswordKey, decPrivateKey);
const userEncKey = new SymmetricCryptoKey(decValue);
// Create new key and hash new password
const newKey = await this.cryptoService.makeKey(
this.newPassword,
this.email.trim().toLowerCase(),
kdfType,
new KdfConfig(kdfIterations, kdfMemory, kdfParallelism)
);
const newPasswordHash = await this.cryptoService.hashPassword(this.newPassword, newKey);
// Create new encKey for the User
const newEncKey = await this.cryptoService.remakeEncKey(newKey, userEncKey);
// Create request
const request = new OrganizationUserResetPasswordRequest();
request.key = newEncKey[1].encryptedString;
request.newMasterPasswordHash = newPasswordHash;
// Change user's password
return this.organizationUserService.putOrganizationUserResetPassword(
this.organizationId,
this.id,
request
);
});
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("resetPasswordSuccess")
);
this.onPasswordReset.emit();
} catch (e) {
this.logService.error(e);
}
}
getStrengthResult(result: zxcvbn.ZXCVBNResult) {
this.passwordStrengthResult = result;
}
}

View File

@@ -0,0 +1,329 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div class="container page-content">
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<h1>{{ "members" | i18n }}</h1>
<div class="tw-flex tw-items-center tw-justify-end tw-space-x-3">
<bit-toggle-group
[selected]="status"
(selectedChange)="filter($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="null">
{{ "all" | i18n }} <span bitBadge badgeType="info" *ngIf="allCount">{{ allCount }}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge badgeType="info" *ngIf="invitedCount">{{ invitedCount }}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge badgeType="info" *ngIf="acceptedCount">{{ acceptedCount }}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge badgeType="info" *ngIf="revokedCount">{{ revokedCount }}</span>
</bit-toggle>
</bit-toggle-group>
<app-search-input
class="tw-grow"
[(ngModel)]="searchText"
[placeholder]="'searchMembers' | i18n"
>
</app-search-input>
<button type="button" bitButton buttonType="primary" (click)="invite()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</div>
</div>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="
!loading &&
(isPaging()
? pagedUsers
: (users | search : searchText : 'name' : 'email' : 'id')) as searchedUsers
"
>
<p *ngIf="!searchedUsers.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="searchedUsers.length">
<app-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</app-callout>
<bit-table
infinite-scroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="selectAll($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell>{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button type="button" bitMenuItem (click)="bulkReinvite()">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button type="button" bitMenuItem (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRevoke()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRemove()">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let u of searchedUsers" alignContent="middle">
<td bitCell (click)="checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<button bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
badgeType="secondary"
*ngIf="u.status === userStatusType.Invited"
>{{ "invited" | i18n }}</span
>
<span
bitBadge
class="tw-text-xs"
badgeType="warning"
*ngIf="u.status === userStatusType.Accepted"
>{{ "needsConfirmation" | i18n }}</span
>
<span
bitBadge
class="tw-text-xs"
badgeType="secondary"
*ngIf="u.status === userStatusType.Revoked"
>{{ "revoked" | i18n }}</span
>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
</div>
</div>
</div>
</td>
<td
bitCell
(click)="edit(u, organization.useGroups ? memberTab.Groups : memberTab.Collections)"
class="tw-cursor-pointer"
>
<bit-badge-list
*ngIf="organization.useGroups || !u.accessAll"
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
badgeType="secondary"
></bit-badge-list>
<span *ngIf="!organization.useGroups && u.accessAll">{{ "all" | i18n }}</span>
</td>
<td
bitCell
(click)="edit(u, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u))">
<i
class="bwi bwi-key"
title="{{ 'enrolledPasswordReset' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledPasswordReset" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Role)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Collections)">
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="events(u)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "resetPassword" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(u)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</ng-container>
</ng-container>
<ng-template #addEdit></ng-template>
<ng-template #groupsTemplate></ng-template>
<ng-template #eventsTemplate></ng-template>
<ng-template #confirmTemplate></ng-template>
<ng-template #resetPasswordTemplate></ng-template>
<ng-template #bulkStatusTemplate></ng-template>
<ng-template #bulkConfirmTemplate></ng-template>
<ng-template #bulkRemoveTemplate></ng-template>
</div>

View File

@@ -0,0 +1,607 @@
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
concatMap,
firstValueFrom,
from,
lastValueFrom,
map,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserConfirmRequest } from "@bitwarden/common/abstractions/organization-user/requests";
import {
OrganizationUserBulkResponse,
OrganizationUserUserDetailsResponse,
} from "@bitwarden/common/abstractions/organization-user/responses";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction as PolicyApiService } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { CollectionData } from "@bitwarden/common/admin-console/models/data/collection.data";
import { Collection } from "@bitwarden/common/admin-console/models/domain/collection";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/response/collection.response";
import { ProductType } from "@bitwarden/common/enums/productType";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import {
DialogService,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/components";
import { EntityEventsComponent } from "../../../admin-console/organizations/manage/entity-events.component";
import { BasePeopleComponent } from "../../../common/base.people.component";
import { GroupService } from "../../../organizations/core";
import { OrganizationUserView } from "../../../organizations/core/views/organization-user.view";
import { BulkConfirmComponent } from "../../../organizations/members/components/bulk/bulk-confirm.component";
import { BulkRemoveComponent } from "../../../organizations/members/components/bulk/bulk-remove.component";
import { BulkRestoreRevokeComponent } from "../../../organizations/members/components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "../../../organizations/members/components/bulk/bulk-status.component";
import {
MemberDialogResult,
MemberDialogTab,
openUserAddEditDialog,
} from "./components/member-dialog";
import { ResetPasswordComponent } from "./components/reset-password.component";
@Component({
selector: "app-org-people",
templateUrl: "people.component.html",
})
export class PeopleComponent
extends BasePeopleComponent<OrganizationUserView>
implements OnInit, OnDestroy
{
@ViewChild("groupsTemplate", { read: ViewContainerRef, static: true })
groupsModalRef: ViewContainerRef;
@ViewChild("eventsTemplate", { read: ViewContainerRef, static: true })
eventsModalRef: ViewContainerRef;
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
confirmModalRef: ViewContainerRef;
@ViewChild("resetPasswordTemplate", { read: ViewContainerRef, static: true })
resetPasswordModalRef: ViewContainerRef;
@ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true })
bulkStatusModalRef: ViewContainerRef;
@ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true })
bulkConfirmModalRef: ViewContainerRef;
@ViewChild("bulkRemoveTemplate", { read: ViewContainerRef, static: true })
bulkRemoveModalRef: ViewContainerRef;
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
organization: Organization;
status: OrganizationUserStatusType = null;
orgResetPasswordPolicyEnabled = false;
private destroy$ = new Subject<void>();
constructor(
apiService: ApiService,
private route: ActivatedRoute,
i18nService: I18nService,
modalService: ModalService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService,
searchService: SearchService,
validationService: ValidationService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
logService: LogService,
searchPipe: SearchPipe,
userNamePipe: UserNamePipe,
private syncService: SyncService,
stateService: StateService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
private dialogService: DialogService,
private router: Router,
private groupService: GroupService,
private collectionService: CollectionService
) {
super(
apiService,
searchService,
i18nService,
platformUtilsService,
cryptoService,
validationService,
modalService,
logService,
searchPipe,
userNamePipe,
stateService
);
}
async ngOnInit() {
const organization$ = this.route.params.pipe(
map((params) => this.organizationService.get(params.organizationId)),
shareReplay({ refCount: true, bufferSize: 1 })
);
const policies$ = organization$.pipe(
switchMap((organization) => {
if (organization.isProviderUser) {
return from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => this.policyService.mapPoliciesFromToken(response))
);
}
return this.policyService.policies$;
})
);
combineLatest([this.route.queryParams, policies$, organization$])
.pipe(
concatMap(async ([qParams, policies, organization]) => {
this.organization = organization;
// Backfill pub/priv key if necessary
if (
this.organization.canManageUsersPassword &&
!this.organization.hasPublicAndPrivateKeys
) {
const orgShareKey = await this.cryptoService.getOrgKey(this.organization.id);
const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey);
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
const response = await this.organizationApiService.updateKeys(
this.organization.id,
request
);
if (response != null) {
this.organization.hasPublicAndPrivateKeys =
response.publicKey != null && response.privateKey != null;
await this.syncService.fullSync(true); // Replace oganizations with new data
} else {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
}
const resetPasswordPolicy = policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === this.organization.id);
this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled;
await this.load();
this.searchText = qParams.search;
if (qParams.viewEvents != null) {
const user = this.users.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.events(user[0]);
}
}
}),
takeUntil(this.destroy$)
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async load() {
await super.load();
}
async getUsers(): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>>;
let collectionsPromise: Promise<Map<string, string>>;
// We don't need both groups and collections for the table, so only load one
const userPromise = this.organizationUserService.getAllUsers(this.organization.id, {
includeGroups: this.organization.useGroups,
includeCollections: !this.organization.useGroups,
});
// Depending on which column is displayed, we need to load the group/collection names
if (this.organization.useGroups) {
groupsPromise = this.getGroupNameMap();
} else {
collectionsPromise = this.getCollectionNameMap();
}
const [usersResponse, groupNamesMap, collectionNamesMap] = await Promise.all([
userPromise,
groupsPromise,
collectionsPromise,
]);
return usersResponse.data?.map<OrganizationUserView>((r) => {
const userView = OrganizationUserView.fromResponse(r);
userView.groupNames = userView.groups
.map((g) => groupNamesMap.get(g))
.sort(this.i18nService.collator?.compare);
userView.collectionNames = userView.collections
.map((c) => collectionNamesMap.get(c.id))
.sort(this.i18nService.collator?.compare);
return userView;
});
}
async getGroupNameMap(): Promise<Map<string, string>> {
const groups = await this.groupService.getAll(this.organization.id);
const groupNameMap = new Map<string, string>();
groups.forEach((g) => groupNameMap.set(g.id, g.name));
return groupNameMap;
}
/**
* Retrieve a map of all collection IDs <-> names for the organization.
*/
async getCollectionNameMap() {
const collectionMap = new Map<string, string>();
const response = await this.apiService.getCollections(this.organization.id);
const collections = response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse))
);
const decryptedCollections = await this.collectionService.decryptMany(collections);
decryptedCollections.forEach((c) => collectionMap.set(c.id, c.name));
return collectionMap;
}
deleteUser(id: string): Promise<void> {
return this.organizationUserService.deleteOrganizationUser(this.organization.id, id);
}
revokeUser(id: string): Promise<void> {
return this.organizationUserService.revokeOrganizationUser(this.organization.id, id);
}
restoreUser(id: string): Promise<void> {
return this.organizationUserService.restoreOrganizationUser(this.organization.id, id);
}
reinviteUser(id: string): Promise<void> {
return this.organizationUserService.postOrganizationUserReinvite(this.organization.id, id);
}
async confirmUser(user: OrganizationUserView, publicKey: Uint8Array): Promise<void> {
const orgKey = await this.cryptoService.getOrgKey(this.organization.id);
const key = await this.cryptoService.rsaEncrypt(orgKey.key, publicKey.buffer);
const request = new OrganizationUserConfirmRequest();
request.key = key.encryptedString;
await this.organizationUserService.postOrganizationUserConfirm(
this.organization.id,
user.id,
request
);
}
allowResetPassword(orgUser: OrganizationUserView): boolean {
// Hierarchy check
let callingUserHasPermission = false;
switch (this.organization.type) {
case OrganizationUserType.Owner:
callingUserHasPermission = true;
break;
case OrganizationUserType.Admin:
callingUserHasPermission = orgUser.type !== OrganizationUserType.Owner;
break;
case OrganizationUserType.Custom:
callingUserHasPermission =
orgUser.type !== OrganizationUserType.Owner &&
orgUser.type !== OrganizationUserType.Admin;
break;
}
// Final
return (
this.organization.canManageUsersPassword &&
callingUserHasPermission &&
this.organization.useResetPassword &&
this.organization.hasPublicAndPrivateKeys &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled &&
orgUser.status === OrganizationUserStatusType.Confirmed
);
}
showEnrolledStatus(orgUser: OrganizationUserUserDetailsResponse): boolean {
return (
this.organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
this.orgResetPasswordPolicyEnabled
);
}
private async showFreeOrgUpgradeDialog(): Promise<void> {
const orgUpgradeSimpleDialogOpts: SimpleDialogOptions = {
title: this.i18nService.t("upgradeOrganization"),
content: this.i18nService.t(
this.organization.canManageBilling
? "freeOrgInvLimitReachedManageBilling"
: "freeOrgInvLimitReachedNoManageBilling",
this.organization.seats
),
type: SimpleDialogType.PRIMARY,
};
if (this.organization.canManageBilling) {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("upgrade");
} else {
orgUpgradeSimpleDialogOpts.acceptButtonText = this.i18nService.t("ok");
orgUpgradeSimpleDialogOpts.cancelButtonText = null; // hide secondary btn
}
const simpleDialog = this.dialogService.openSimpleDialog(orgUpgradeSimpleDialogOpts);
firstValueFrom(simpleDialog.closed).then((result: SimpleDialogCloseType | undefined) => {
if (!result) {
return;
}
if (result == SimpleDialogCloseType.ACCEPT && this.organization.canManageBilling) {
this.router.navigate(["/organizations", this.organization.id, "billing", "subscription"], {
queryParams: { upgrade: true },
});
}
});
}
async edit(user: OrganizationUserView, initialTab: MemberDialogTab = MemberDialogTab.Role) {
// Invite User: Add Flow
// Click on user email: Edit Flow
// User attempting to invite new users in a free org with max users
if (
!user &&
this.organization.planProductType === ProductType.Free &&
this.allUsers.length === this.organization.seats
) {
// Show org upgrade modal
await this.showFreeOrgUpgradeDialog();
return;
}
const dialog = openUserAddEditDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationUserId: user != null ? user.id : null,
usesKeyConnector: user?.usesKeyConnector,
initialTab: initialTab,
},
});
const result = await lastValueFrom(dialog.closed);
switch (result) {
case MemberDialogResult.Deleted:
this.removeUser(user);
break;
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
this.load();
break;
}
}
async bulkRemove() {
if (this.actionPromise != null) {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkRemoveComponent,
this.bulkRemoveModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
}
);
await modal.onClosedPromise();
await this.load();
}
async bulkRevoke() {
await this.bulkRevokeOrRestore(true);
}
async bulkRestore() {
await this.bulkRevokeOrRestore(false);
}
async bulkRevokeOrRestore(isRevoking: boolean) {
if (this.actionPromise != null) {
return;
}
const ref = this.modalService.open(BulkRestoreRevokeComponent, {
allowMultipleModals: true,
data: {
organizationId: this.organization.id,
users: this.getCheckedUsers(),
isRevoking: isRevoking,
},
});
await ref.onClosedPromise();
await this.load();
}
async bulkReinvite() {
if (this.actionPromise != null) {
return;
}
const users = this.getCheckedUsers();
const filteredUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
if (filteredUsers.length <= 0) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("noSelectedUsersApplicable")
);
return;
}
try {
const response = this.organizationUserService.postManyOrganizationUserReinvite(
this.organization.id,
filteredUsers.map((user) => user.id)
);
this.showBulkStatus(
users,
filteredUsers,
response,
this.i18nService.t("bulkReinviteMessage")
);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async bulkConfirm() {
if (this.actionPromise != null) {
return;
}
const [modal] = await this.modalService.openViewRef(
BulkConfirmComponent,
this.bulkConfirmModalRef,
(comp) => {
comp.organizationId = this.organization.id;
comp.users = this.getCheckedUsers();
}
);
await modal.onClosedPromise();
await this.load();
}
async events(user: OrganizationUserView) {
await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => {
comp.name = this.userNamePipe.transform(user);
comp.organizationId = this.organization.id;
comp.entityId = user.id;
comp.showUser = false;
comp.entity = "user";
});
}
async resetPassword(user: OrganizationUserView) {
const [modal] = await this.modalService.openViewRef(
ResetPasswordComponent,
this.resetPasswordModalRef,
(comp) => {
comp.name = this.userNamePipe.transform(user);
comp.email = user != null ? user.email : null;
comp.organizationId = this.organization.id;
comp.id = user != null ? user.id : null;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onPasswordReset.subscribe(() => {
modal.close();
this.load();
});
}
);
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
const warningMessage = user.usesKeyConnector
? this.i18nService.t("removeUserConfirmationKeyConnector")
: this.i18nService.t("removeOrgUserConfirmation");
return this.platformUtilsService.showDialog(
warningMessage,
this.i18nService.t("removeUserIdAccess", this.userNamePipe.transform(user)),
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
}
private async showBulkStatus(
users: OrganizationUserView[],
filteredUsers: OrganizationUserView[],
request: Promise<ListResponse<OrganizationUserBulkResponse>>,
successfullMessage: string
) {
const [modal, childComponent] = await this.modalService.openViewRef(
BulkStatusComponent,
this.bulkStatusModalRef,
(comp) => {
comp.loading = true;
}
);
// Workaround to handle closing the modal shortly after it has been opened
let close = false;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
modal.onShown.subscribe(() => {
if (close) {
modal.close();
}
});
try {
const response = await request;
if (modal) {
const keyedErrors: any = response.data
.filter((r) => r.error !== "")
.reduce((a, x) => ({ ...a, [x.id]: x.error }), {});
const keyedFilteredUsers: any = filteredUsers.reduce((a, x) => ({ ...a, [x.id]: x }), {});
childComponent.users = users.map((user) => {
let message = keyedErrors[user.id] ?? successfullMessage;
// eslint-disable-next-line
if (!keyedFilteredUsers.hasOwnProperty(user.id)) {
message = this.i18nService.t("bulkFilteredMessage");
}
return {
user: user,
error: keyedErrors.hasOwnProperty(user.id), // eslint-disable-line
message: message,
};
});
childComponent.loading = false;
}
} catch {
close = true;
modal.close();
}
}
}

View File

@@ -0,0 +1,119 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard";
import {
canAccessOrgAdmin,
canAccessGroupsTab,
canAccessMembersTab,
canAccessVaultTab,
canAccessReportingTab,
canAccessSettingsTab,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard";
import { OrganizationRedirectGuard } from "../../admin-console/organizations/guards/org-redirect.guard";
import { OrganizationLayoutComponent } from "../../admin-console/organizations/layouts/organization-layout.component";
import { CollectionsComponent } from "../../admin-console/organizations/manage/collections.component";
import { GroupsComponent } from "../../admin-console/organizations/manage/groups.component";
import { ManageComponent } from "../../admin-console/organizations/manage/manage.component";
import { VaultModule } from "../../vault/org-vault/vault.module";
const routes: Routes = [
{
path: ":organizationId",
component: OrganizationLayoutComponent,
canActivate: [AuthGuard, OrganizationPermissionsGuard],
data: {
organizationPermissions: canAccessOrgAdmin,
},
children: [
{
path: "",
pathMatch: "full",
canActivate: [OrganizationRedirectGuard],
data: {
autoRedirectCallback: getOrganizationRoute,
},
children: [], // This is required to make the auto redirect work, },
},
{
path: "vault",
loadChildren: () => VaultModule,
},
{
path: "settings",
loadChildren: () =>
import("./settings/organization-settings.module").then(
(m) => m.OrganizationSettingsModule
),
},
{
path: "members",
loadChildren: () => import("../../organizations/members").then((m) => m.MembersModule),
},
{
path: "groups",
component: GroupsComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "groups",
organizationPermissions: canAccessGroupsTab,
},
},
{
path: "manage",
component: ManageComponent,
children: [
{
path: "collections",
component: CollectionsComponent,
data: {
titleId: "collections",
},
},
],
},
{
path: "reporting",
loadChildren: () =>
import("../organizations/reporting/organization-reporting.module").then(
(m) => m.OrganizationReportingModule
),
},
{
path: "billing",
loadChildren: () =>
import("../../billing/organizations/organization-billing.module").then(
(m) => m.OrganizationBillingModule
),
},
],
},
];
function getOrganizationRoute(organization: Organization): string {
if (canAccessVaultTab(organization)) {
return "vault";
}
if (canAccessMembersTab(organization)) {
return "members";
}
if (canAccessGroupsTab(organization)) {
return "groups";
}
if (canAccessReportingTab(organization)) {
return "reporting";
}
if (canAccessSettingsTab(organization)) {
return "settings";
}
return undefined;
}
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OrganizationsRoutingModule {}

View File

@@ -0,0 +1,56 @@
import { Directive, Input, OnInit } from "@angular/core";
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
export abstract class BasePolicy {
abstract name: string;
abstract description: string;
abstract type: PolicyType;
abstract component: any;
display(organization: Organization) {
return true;
}
}
@Directive()
export abstract class BasePolicyComponent implements OnInit {
@Input() policyResponse: PolicyResponse;
@Input() policy: BasePolicy;
enabled = new UntypedFormControl(false);
data: UntypedFormGroup = null;
ngOnInit(): void {
this.enabled.setValue(this.policyResponse.enabled);
if (this.policyResponse.data != null) {
this.loadData();
}
}
loadData() {
this.data.patchValue(this.policyResponse.data ?? {});
}
buildRequestData() {
if (this.data != null) {
return this.data.value;
}
return null;
}
buildRequest(policiesEnabledMap: Map<PolicyType, boolean>) {
const request = new PolicyRequest();
request.enabled = this.enabled.value;
request.type = this.policy.type;
request.data = this.buildRequestData();
return Promise.resolve(request);
}
}

View File

@@ -0,0 +1,16 @@
<app-callout type="warning">
{{ "disableSendExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class DisableSendPolicy extends BasePolicy {
name = "disableSend";
description = "disableSendPolicyDesc";
type = PolicyType.DisableSend;
component = DisableSendPolicyComponent;
}
@Component({
selector: "policy-disable-send",
templateUrl: "disable-send.component.html",
})
export class DisableSendPolicyComponent extends BasePolicyComponent {}

View File

@@ -0,0 +1,12 @@
export * from "./policies.module";
export { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export { DisableSendPolicy } from "./disable-send.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { PersonalOwnershipPolicy } from "./personal-ownership.component";
export { RequireSsoPolicy } from "./require-sso.component";
export { ResetPasswordPolicy } from "./reset-password.component";
export { SendOptionsPolicy } from "./send-options.component";
export { SingleOrgPolicy } from "./single-org.component";
export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component";
export { PoliciesComponent } from "./policies.component";

View File

@@ -0,0 +1,83 @@
<app-callout type="info" *ngIf="showKeyConnectorInfo">
{{ "keyConnectorPolicyRestriction" | i18n }}
</app-callout>
<div [formGroup]="data">
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="minComplexity">{{ "minComplexityScore" | i18n }}</label>
<select
id="minComplexity"
name="minComplexity"
formControlName="minComplexity"
class="form-control"
>
<option *ngFor="let o of passwordScores" [ngValue]="o.value">{{ o.name }}</option>
</select>
</div>
<div class="col-6 form-group">
<label for="minLength">{{ "minLength" | i18n }}</label>
<input
id="minLength"
class="form-control"
type="number"
min="8"
name="minLength"
formControlName="minLength"
/>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="requireUpper"
name="requireUpper"
formControlName="requireUpper"
/>
<label class="form-check-label" for="requireUpper">A-Z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="requireLower"
name="requireLower"
formControlName="requireLower"
/>
<label class="form-check-label" for="requireLower">a-z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="requireNumbers"
name="requireNumbers"
formControlName="requireNumbers"
/>
<label class="form-check-label" for="requireNumbers">0-9</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="requireSpecial"
name="requireSpecial"
formControlName="requireSpecial"
/>
<label class="form-check-label" for="requireSpecial">!@#$%^&amp;*</label>
</div>
</div>

View File

@@ -0,0 +1,56 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class MasterPasswordPolicy extends BasePolicy {
name = "masterPassPolicyTitle";
description = "masterPassPolicyDesc";
type = PolicyType.MasterPassword;
component = MasterPasswordPolicyComponent;
}
@Component({
selector: "policy-master-password",
templateUrl: "master-password.component.html",
})
export class MasterPasswordPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
minComplexity: [null],
minLength: [null],
requireUpper: [null],
requireLower: [null],
requireNumbers: [null],
requireSpecial: [null],
});
passwordScores: { name: string; value: number }[];
showKeyConnectorInfo = false;
constructor(
private formBuilder: UntypedFormBuilder,
i18nService: I18nService,
private organizationService: OrganizationService
) {
super();
this.passwordScores = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: i18nService.t("weak") + " (0)", value: 0 },
{ name: i18nService.t("weak") + " (1)", value: 1 },
{ name: i18nService.t("weak") + " (2)", value: 2 },
{ name: i18nService.t("good") + " (3)", value: 3 },
{ name: i18nService.t("strong") + " (4)", value: 4 },
];
}
async ngOnInit() {
super.ngOnInit();
const organization = await this.organizationService.get(this.policyResponse.organizationId);
this.showKeyConnectorInfo = organization.keyConnectorEnabled;
}
}

View File

@@ -0,0 +1,144 @@
<div [formGroup]="data">
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<div class="row">
<div class="col-6 form-group mb-0">
<label for="defaultType">{{ "defaultType" | i18n }}</label>
<select
id="defaultType"
name="defaultType"
formControlName="defaultType"
class="form-control"
>
<option *ngFor="let o of defaultTypes" [ngValue]="o.value">{{ o.name }}</option>
</select>
</div>
</div>
<h3 class="mt-4">{{ "password" | i18n }}</h3>
<div class="row">
<div class="col-6 form-group">
<label for="minLength">{{ "minLength" | i18n }}</label>
<input
id="minLength"
class="form-control"
type="number"
name="minLength"
min="5"
max="128"
formControlName="minLength"
/>
</div>
</div>
<div class="row">
<div class="col-6 form-group">
<label for="minNumbers">{{ "minNumbers" | i18n }}</label>
<input
id="minNumbers"
class="form-control"
type="number"
name="minNumbers"
min="0"
max="9"
formControlName="minNumbers"
/>
</div>
<div class="col-6 form-group">
<label for="minSpecial">{{ "minSpecial" | i18n }}</label>
<input
id="minSpecial"
class="form-control"
type="number"
name="minSpecial"
min="0"
max="9"
formControlName="minSpecial"
/>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useUpper"
formControlName="useUpper"
name="useUpper"
/>
<label class="form-check-label" for="useUpper">A-Z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useLower"
name="useLower"
formControlName="useLower"
/>
<label class="form-check-label" for="useLower">a-z</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useNumbers"
name="useNumbers"
formControlName="useNumbers"
/>
<label class="form-check-label" for="useNumbers">0-9</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="useSpecial"
name="useSpecial"
formControlName="useSpecial"
/>
<label class="form-check-label" for="useSpecial">!@#$%^&amp;*</label>
</div>
<h3 class="mt-4">{{ "passphrase" | i18n }}</h3>
<div class="row">
<div class="col-6 form-group">
<label for="minNumberWords">{{ "minimumNumberOfWords" | i18n }}</label>
<input
id="minNumberWords"
class="form-control"
type="number"
name="minNumberWords"
min="3"
max="20"
formControlName="minNumberWords"
/>
</div>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="capitalize"
name="capitalize"
formControlName="capitalize"
/>
<label class="form-check-label" for="capitalize">{{ "capitalize" | i18n }}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="includeNumber"
name="includeNumber"
formControlName="includeNumber"
/>
<label class="form-check-label" for="includeNumber">{{ "includeNumber" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,46 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class PasswordGeneratorPolicy extends BasePolicy {
name = "passwordGenerator";
description = "passwordGeneratorPolicyDesc";
type = PolicyType.PasswordGenerator;
component = PasswordGeneratorPolicyComponent;
}
@Component({
selector: "policy-password-generator",
templateUrl: "password-generator.component.html",
})
export class PasswordGeneratorPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
defaultType: [null],
minLength: [null],
useUpper: [null],
useLower: [null],
useNumbers: [null],
useSpecial: [null],
minNumbers: [null],
minSpecial: [null],
minNumberWords: [null],
capitalize: [null],
includeNumber: [null],
});
defaultTypes: { name: string; value: string }[];
constructor(private formBuilder: UntypedFormBuilder, i18nService: I18nService) {
super();
this.defaultTypes = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t("password"), value: "password" },
{ name: i18nService.t("passphrase"), value: "passphrase" },
];
}
}

View File

@@ -0,0 +1,16 @@
<app-callout type="warning">
{{ "personalOwnershipExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class PersonalOwnershipPolicy extends BasePolicy {
name = "personalOwnership";
description = "personalOwnershipPolicyDesc";
type = PolicyType.PersonalOwnership;
component = PersonalOwnershipPolicyComponent;
}
@Component({
selector: "policy-personal-ownership",
templateUrl: "personal-ownership.component.html",
})
export class PersonalOwnershipPolicyComponent extends BasePolicyComponent {}

View File

@@ -0,0 +1,25 @@
<div class="page-header d-flex">
<h1>{{ "policies" | i18n }}</h1>
</div>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<table class="table table-hover table-list" *ngIf="!loading">
<tbody>
<tr *ngFor="let p of policies">
<td *ngIf="p.display(organization)">
<a href="#" appStopClick (click)="edit(p)">{{ p.name | i18n }}</a>
<span bitBadge badgeType="success" *ngIf="policiesEnabledMap.get(p.type)">{{
"on" | i18n
}}</span>
<small class="text-muted d-block">{{ p.description | i18n }}</small>
</td>
</tr>
</tbody>
</table>
<ng-template #editTemplate></ng-template>

View File

@@ -0,0 +1,99 @@
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { PolicyListService } from "../../../core";
import { BasePolicy } from "../policies";
import { PolicyEditComponent } from "./policy-edit.component";
@Component({
selector: "app-org-policies",
templateUrl: "policies.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class PoliciesComponent implements OnInit {
@ViewChild("editTemplate", { read: ViewContainerRef, static: true })
editModalRef: ViewContainerRef;
loading = true;
organizationId: string;
policies: BasePolicy[];
organization: Organization;
private orgPolicies: PolicyResponse[];
protected policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
constructor(
private route: ActivatedRoute,
private modalService: ModalService,
private organizationService: OrganizationService,
private policyApiService: PolicyApiServiceAbstraction,
private policyListService: PolicyListService,
private router: Router
) {}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
this.organization = await this.organizationService.get(this.organizationId);
this.policies = this.policyListService.getPolicies();
await this.load();
// Handle policies component launch from Event message
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
this.edit(this.policies[i]);
break;
}
}
break;
}
}
}
});
});
}
async load() {
const response = await this.policyApiService.getPolicies(this.organizationId);
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
this.orgPolicies.forEach((op) => {
this.policiesEnabledMap.set(op.type, op.enabled);
});
this.loading = false;
}
async edit(policy: BasePolicy) {
const [modal] = await this.modalService.openViewRef(
PolicyEditComponent,
this.editModalRef,
(comp) => {
comp.policy = policy;
comp.organizationId = this.organizationId;
comp.policiesEnabledMap = this.policiesEnabledMap;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSavedPolicy.subscribe(() => {
modal.close();
this.load();
});
}
);
}
}

View File

@@ -0,0 +1,46 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { DisableSendPolicyComponent } from "./disable-send.component";
import { MasterPasswordPolicyComponent } from "./master-password.component";
import { PasswordGeneratorPolicyComponent } from "./password-generator.component";
import { PersonalOwnershipPolicyComponent } from "./personal-ownership.component";
import { PoliciesComponent } from "./policies.component";
import { PolicyEditComponent } from "./policy-edit.component";
import { RequireSsoPolicyComponent } from "./require-sso.component";
import { ResetPasswordPolicyComponent } from "./reset-password.component";
import { SendOptionsPolicyComponent } from "./send-options.component";
import { SingleOrgPolicyComponent } from "./single-org.component";
import { TwoFactorAuthenticationPolicyComponent } from "./two-factor-authentication.component";
@NgModule({
imports: [SharedModule, LooseComponentsModule],
declarations: [
DisableSendPolicyComponent,
MasterPasswordPolicyComponent,
PasswordGeneratorPolicyComponent,
PersonalOwnershipPolicyComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SendOptionsPolicyComponent,
SingleOrgPolicyComponent,
TwoFactorAuthenticationPolicyComponent,
PoliciesComponent,
PolicyEditComponent,
],
exports: [
DisableSendPolicyComponent,
MasterPasswordPolicyComponent,
PasswordGeneratorPolicyComponent,
PersonalOwnershipPolicyComponent,
RequireSsoPolicyComponent,
ResetPasswordPolicyComponent,
SendOptionsPolicyComponent,
SingleOrgPolicyComponent,
TwoFactorAuthenticationPolicyComponent,
PoliciesComponent,
PolicyEditComponent,
],
})
export class PoliciesModule {}

View File

@@ -0,0 +1,49 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="policiesEditTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="policiesEditTitle">
{{ "editPolicy" | i18n }} - {{ policy.name | i18n }}
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="modal-body" *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div [hidden]="loading">
<p>{{ policy.description | i18n }}</p>
<ng-template #policyForm></ng-template>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "cancel" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,107 @@
import {
ChangeDetectorRef,
Component,
ComponentFactoryResolver,
EventEmitter,
Input,
Output,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { BasePolicy, BasePolicyComponent } from "../policies";
@Component({
selector: "app-policy-edit",
templateUrl: "policy-edit.component.html",
})
export class PolicyEditComponent {
@Input() policy: BasePolicy;
@Input() organizationId: string;
@Input() policiesEnabledMap: Map<PolicyType, boolean> = new Map<PolicyType, boolean>();
@Output() onSavedPolicy = new EventEmitter();
@ViewChild("policyForm", { read: ViewContainerRef, static: true })
policyFormRef: ViewContainerRef;
policyType = PolicyType;
loading = true;
enabled = false;
formPromise: Promise<any>;
defaultTypes: any[];
policyComponent: BasePolicyComponent;
private policyResponse: PolicyResponse;
constructor(
private policyApiService: PolicyApiServiceAbstraction,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private componentFactoryResolver: ComponentFactoryResolver,
private cdr: ChangeDetectorRef,
private logService: LogService
) {}
async ngAfterViewInit() {
await this.load();
this.loading = false;
const factory = this.componentFactoryResolver.resolveComponentFactory(this.policy.component);
this.policyComponent = this.policyFormRef.createComponent(factory)
.instance as BasePolicyComponent;
this.policyComponent.policy = this.policy;
this.policyComponent.policyResponse = this.policyResponse;
this.cdr.detectChanges();
}
async load() {
try {
this.policyResponse = await this.policyApiService.getPolicy(
this.organizationId,
this.policy.type
);
} catch (e) {
if (e.statusCode === 404) {
this.policyResponse = new PolicyResponse({ Enabled: false });
} else {
throw e;
}
}
}
async submit() {
let request: PolicyRequest;
try {
request = await this.policyComponent.buildRequest(this.policiesEnabledMap);
} catch (e) {
this.platformUtilsService.showToast("error", null, e.message);
return;
}
try {
this.formPromise = this.policyApiService.putPolicy(
this.organizationId,
this.policy.type,
request
);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("editedPolicyId", this.i18nService.t(this.policy.name))
);
this.onSavedPolicy.emit();
} catch (e) {
this.logService.error(e);
}
}
}

View File

@@ -0,0 +1,19 @@
<app-callout type="tip" title="{{ 'prerequisite' | i18n }}">
{{ "requireSsoPolicyReq" | i18n }}
</app-callout>
<app-callout type="warning">
{{ "requireSsoExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,38 @@
import { Component } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class RequireSsoPolicy extends BasePolicy {
name = "requireSso";
description = "requireSsoPolicyDesc";
type = PolicyType.RequireSso;
component = RequireSsoPolicyComponent;
display(organization: Organization) {
return organization.useSso;
}
}
@Component({
selector: "policy-require-sso",
templateUrl: "require-sso.component.html",
})
export class RequireSsoPolicyComponent extends BasePolicyComponent {
constructor(private i18nService: I18nService) {
super();
}
buildRequest(policiesEnabledMap: Map<PolicyType, boolean>): Promise<PolicyRequest> {
const singleOrgEnabled = policiesEnabledMap.get(PolicyType.SingleOrg) ?? false;
if (this.enabled.value && !singleOrgEnabled) {
throw new Error(this.i18nService.t("requireSsoPolicyReqError"));
}
return super.buildRequest(policiesEnabledMap);
}
}

View File

@@ -0,0 +1,40 @@
<app-callout type="info" *ngIf="showKeyConnectorInfo">
{{ "keyConnectorPolicyRestriction" | i18n }}
</app-callout>
<app-callout type="warning">
{{ "resetPasswordPolicyWarning" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<div [formGroup]="data">
<h3 class="mt-4">{{ "resetPasswordPolicyAutoEnroll" | i18n }}</h3>
<p>{{ "resetPasswordPolicyAutoEnrollDescription" | i18n }}</p>
<app-callout type="warning">
{{ "resetPasswordPolicyAutoEnrollWarning" | i18n }}
</app-callout>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="autoEnrollEnabled"
name="AutoEnrollEnabled"
formControlName="autoEnrollEnabled"
/>
<label class="form-check-label" for="autoEnrollEnabled">
{{ "resetPasswordPolicyAutoEnrollCheckbox" | i18n }}
</label>
</div>
</div>

View File

@@ -0,0 +1,45 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class ResetPasswordPolicy extends BasePolicy {
name = "resetPasswordPolicy";
description = "resetPasswordPolicyDescription";
type = PolicyType.ResetPassword;
component = ResetPasswordPolicyComponent;
display(organization: Organization) {
return organization.useResetPassword;
}
}
@Component({
selector: "policy-reset-password",
templateUrl: "reset-password.component.html",
})
export class ResetPasswordPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
autoEnrollEnabled: false,
});
defaultTypes: { name: string; value: string }[];
showKeyConnectorInfo = false;
constructor(
private formBuilder: UntypedFormBuilder,
private organizationService: OrganizationService
) {
super();
}
async ngOnInit() {
super.ngOnInit();
const organization = await this.organizationService.get(this.policyResponse.organizationId);
this.showKeyConnectorInfo = organization.keyConnectorEnabled;
}
}

View File

@@ -0,0 +1,30 @@
<app-callout type="warning">
{{ "sendOptionsExemption" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>
<div [formGroup]="data">
<h3 class="mt-4">{{ "options" | i18n }}</h3>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="disableHideEmail"
name="DisableHideEmail"
formControlName="disableHideEmail"
/>
<label class="form-check-label" for="disableHideEmail">{{ "disableHideEmail" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,27 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class SendOptionsPolicy extends BasePolicy {
name = "sendOptions";
description = "sendOptionsPolicyDesc";
type = PolicyType.SendOptions;
component = SendOptionsPolicyComponent;
}
@Component({
selector: "policy-send-options",
templateUrl: "send-options.component.html",
})
export class SendOptionsPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
disableHideEmail: false,
});
constructor(private formBuilder: UntypedFormBuilder) {
super();
}
}

View File

@@ -0,0 +1,16 @@
<app-callout type="warning">
{{ "singleOrgPolicyWarning" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,42 @@
import { Component } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class SingleOrgPolicy extends BasePolicy {
name = "singleOrg";
description = "singleOrgDesc";
type = PolicyType.SingleOrg;
component = SingleOrgPolicyComponent;
}
@Component({
selector: "policy-single-org",
templateUrl: "single-org.component.html",
})
export class SingleOrgPolicyComponent extends BasePolicyComponent {
constructor(private i18nService: I18nService) {
super();
}
buildRequest(policiesEnabledMap: Map<PolicyType, boolean>): Promise<PolicyRequest> {
if (!this.enabled.value) {
if (policiesEnabledMap.get(PolicyType.RequireSso) ?? false) {
throw new Error(
this.i18nService.t("disableRequiredError", this.i18nService.t("requireSso"))
);
}
if (policiesEnabledMap.get(PolicyType.MaximumVaultTimeout) ?? false) {
throw new Error(
this.i18nService.t("disableRequiredError", this.i18nService.t("maximumVaultTimeoutLabel"))
);
}
}
return super.buildRequest(policiesEnabledMap);
}
}

View File

@@ -0,0 +1,16 @@
<app-callout type="warning">
{{ "twoStepLoginPolicyWarning" | i18n }}
</app-callout>
<div class="form-group">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="enabled"
[formControl]="enabled"
name="Enabled"
/>
<label class="form-check-label" for="enabled">{{ "turnOn" | i18n }}</label>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { Component } from "@angular/core";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class TwoFactorAuthenticationPolicy extends BasePolicy {
name = "twoStepLoginPolicyTitle";
description = "twoStepLoginPolicyDesc";
type = PolicyType.TwoFactorAuthentication;
component = TwoFactorAuthenticationPolicyComponent;
}
@Component({
selector: "policy-two-factor-authentication",
templateUrl: "two-factor-authentication.component.html",
})
export class TwoFactorAuthenticationPolicyComponent extends BasePolicyComponent {}

View File

@@ -0,0 +1,107 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessReportingTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ExposedPasswordsReportComponent } from "../../../admin-console/organizations/tools/exposed-passwords-report.component";
import { InactiveTwoFactorReportComponent } from "../../../admin-console/organizations/tools/inactive-two-factor-report.component";
import { ReusedPasswordsReportComponent } from "../../../admin-console/organizations/tools/reused-passwords-report.component";
import { UnsecuredWebsitesReportComponent } from "../../../admin-console/organizations/tools/unsecured-websites-report.component";
import { WeakPasswordsReportComponent } from "../../../admin-console/organizations/tools/weak-passwords-report.component";
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
import { OrganizationRedirectGuard } from "../guards/org-redirect.guard";
import { EventsComponent } from "../manage/events.component";
import { ReportingComponent } from "./reporting.component";
import { ReportsHomeComponent } from "./reports-home.component";
const routes: Routes = [
{
path: "",
component: ReportingComponent,
canActivate: [OrganizationPermissionsGuard],
data: { organizationPermissions: canAccessReportingTab },
children: [
{
path: "",
pathMatch: "full",
canActivate: [OrganizationRedirectGuard],
data: {
autoRedirectCallback: getReportRoute,
},
children: [], // This is required to make the auto redirect work,
},
{
path: "reports",
component: ReportsHomeComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "reports",
},
children: [
{
path: "exposed-passwords-report",
component: ExposedPasswordsReportComponent,
data: {
titleId: "exposedPasswordsReport",
},
},
{
path: "inactive-two-factor-report",
component: InactiveTwoFactorReportComponent,
data: {
titleId: "inactive2faReport",
},
},
{
path: "reused-passwords-report",
component: ReusedPasswordsReportComponent,
data: {
titleId: "reusedPasswordsReport",
},
},
{
path: "unsecured-websites-report",
component: UnsecuredWebsitesReportComponent,
data: {
titleId: "unsecuredWebsitesReport",
},
},
{
path: "weak-passwords-report",
component: WeakPasswordsReportComponent,
data: {
titleId: "weakPasswordsReport",
},
},
],
},
{
path: "events",
component: EventsComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "eventLogs",
organizationPermissions: (org: Organization) => org.canAccessEventLogs,
},
},
],
},
];
function getReportRoute(organization: Organization): string {
if (organization.canAccessEventLogs) {
return "events";
}
if (organization.canAccessReports) {
return "reports";
}
return undefined;
}
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OrganizationReportingRoutingModule {}

View File

@@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { ReportsSharedModule } from "../../../reports";
import { SharedModule } from "../../../shared/shared.module";
import { OrganizationReportingRoutingModule } from "./organization-reporting-routing.module";
import { ReportingComponent } from "./reporting.component";
import { ReportsHomeComponent } from "./reports-home.component";
@NgModule({
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule],
declarations: [ReportsHomeComponent, ReportingComponent],
})
export class OrganizationReportingModule {}

View File

@@ -0,0 +1,30 @@
<div class="container page-content">
<div class="row">
<div class="col-3" *ngIf="showLeftNav$ | async">
<div class="card" *ngIf="organization$ | async as org">
<div class="card-header">{{ "reporting" | i18n }}</div>
<div class="list-group list-group-flush">
<a
routerLink="events"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canAccessEventLogs"
>
{{ "eventLogs" | i18n }}
</a>
<a
routerLink="reports"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canAccessReports"
>
{{ "reports" | i18n }}
</a>
</div>
</div>
</div>
<div class="col-9" [ngClass]="(showLeftNav$ | async) ? 'col-9' : 'col-12'">
<router-outlet></router-outlet>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map, Observable, shareReplay, startWith, switchMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Component({
selector: "app-org-reporting",
templateUrl: "reporting.component.html",
})
export class ReportingComponent implements OnInit {
organization$: Observable<Organization>;
showLeftNav$: Observable<boolean>;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
this.organization$ = this.route.params.pipe(
switchMap((params) => this.organizationService.get$(params.organizationId)),
shareReplay({ refCount: true, bufferSize: 1 })
);
this.showLeftNav$ = this.organization$.pipe(
map((o) => o.canAccessEventLogs && o.canAccessReports),
startWith(true)
);
}
}

View File

@@ -0,0 +1,20 @@
<ng-container *ngIf="homepage">
<div class="page-header">
<h1>{{ "reports" | i18n }}</h1>
</div>
<p>{{ "orgsReportsDesc" | i18n }}</p>
<app-report-list [reports]="reports"></app-report-list>
</ng-container>
<router-outlet></router-outlet>
<div class="row mt-4">
<div class="col">
<a bitButton routerLink="./" *ngIf="!homepage">
<i class="bwi bwi-angle-left" aria-hidden="true"></i>
{{ "backToReports" | i18n }}
</a>
</div>
</div>

View File

@@ -0,0 +1,65 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router";
import { filter, Subject, takeUntil } from "rxjs";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { ReportVariant, reports, ReportType, ReportEntry } from "../../../reports";
@Component({
selector: "app-org-reports-home",
templateUrl: "reports-home.component.html",
})
export class ReportsHomeComponent implements OnInit, OnDestroy {
reports: ReportEntry[];
homepage = true;
private destrory$: Subject<void> = new Subject<void>();
constructor(private stateService: StateService, router: Router) {
router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntil(this.destrory$)
)
.subscribe((event) => {
this.homepage = (event as NavigationEnd).urlAfterRedirects.endsWith("/reports");
});
}
async ngOnInit(): Promise<void> {
const userHasPremium = await this.stateService.getCanAccessPremium();
const reportRequiresPremium = userHasPremium
? ReportVariant.Enabled
: ReportVariant.RequiresPremium;
this.reports = [
{
...reports[ReportType.ExposedPasswords],
variant: reportRequiresPremium,
},
{
...reports[ReportType.ReusedPasswords],
variant: reportRequiresPremium,
},
{
...reports[ReportType.WeakPasswords],
variant: reportRequiresPremium,
},
{
...reports[ReportType.UnsecuredWebsites],
variant: reportRequiresPremium,
},
{
...reports[ReportType.Inactive2fa],
variant: reportRequiresPremium,
},
];
}
ngOnDestroy(): void {
this.destrory$.next();
this.destrory$.complete();
}
}

View File

@@ -0,0 +1,97 @@
<div class="page-header">
<h1>{{ "organizationInfo" | i18n }}</h1>
</div>
<div *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<form
*ngIf="org && !loading"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="row">
<div class="col-6">
<div class="form-group">
<label for="name">{{ "organizationName" | i18n }}</label>
<input
id="name"
class="form-control"
type="text"
name="Name"
[(ngModel)]="org.name"
[disabled]="selfHosted"
/>
</div>
<div class="form-group">
<label for="billingEmail">{{ "billingEmail" | i18n }}</label>
<input
id="billingEmail"
class="form-control"
type="text"
name="BillingEmail"
[(ngModel)]="org.billingEmail"
[disabled]="selfHosted || !canManageBilling"
/>
</div>
<div class="form-group">
<label for="businessName">{{ "businessName" | i18n }}</label>
<input
id="businessName"
class="form-control"
type="text"
name="BusinessName"
[(ngModel)]="org.businessName"
[disabled]="selfHosted || !canManageBilling"
/>
</div>
</div>
<div class="col-6">
<bit-avatar [text]="org.name" [id]="org.id" size="large"></bit-avatar>
</div>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ "save" | i18n }}
</button>
</form>
<ng-container *ngIf="canUseApi">
<div class="secondary-header border-0 mb-0">
<h1>{{ "apiKey" | i18n }}</h1>
</div>
<p>
{{ "apiKeyDesc" | i18n }}
<a href="https://docs.bitwarden.com" target="_blank" rel="noopener">
{{ "learnMore" | i18n }}
</a>
</p>
<button type="button" class="btn btn-outline-secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
</ng-container>
<div class="secondary-header text-danger border-0 mb-0">
<h1>{{ "dangerZone" | i18n }}</h1>
</div>
<div class="card border-danger">
<div class="card-body">
<p>{{ "dangerZoneDesc" | i18n }}</p>
<button type="button" class="btn btn-outline-danger" (click)="deleteOrganization()">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" class="btn btn-outline-danger" (click)="purgeVault()">
{{ "purgeVault" | i18n }}
</button>
</div>
</div>
<ng-template #deleteOrganizationTemplate></ng-template>
<ng-template #purgeOrganizationTemplate></ng-template>
<ng-template #apiKeyTemplate></ng-template>
<ng-template #rotateApiKeyTemplate></ng-template>

View File

@@ -0,0 +1,148 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.request";
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { ApiKeyComponent } from "../../../settings/api-key.component";
import { PurgeVaultComponent } from "../../../settings/purge-vault.component";
import { DeleteOrganizationComponent } from "./delete-organization.component";
@Component({
selector: "app-org-account",
templateUrl: "account.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class AccountComponent {
@ViewChild("deleteOrganizationTemplate", { read: ViewContainerRef, static: true })
deleteModalRef: ViewContainerRef;
@ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true })
purgeModalRef: ViewContainerRef;
@ViewChild("apiKeyTemplate", { read: ViewContainerRef, static: true })
apiKeyModalRef: ViewContainerRef;
@ViewChild("rotateApiKeyTemplate", { read: ViewContainerRef, static: true })
rotateApiKeyModalRef: ViewContainerRef;
selfHosted = false;
canManageBilling = true;
loading = true;
canUseApi = false;
org: OrganizationResponse;
formPromise: Promise<OrganizationResponse>;
taxFormPromise: Promise<unknown>;
private organizationId: string;
constructor(
private modalService: ModalService,
private i18nService: I18nService,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private logService: LogService,
private router: Router,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction
) {}
async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost();
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
this.canManageBilling = this.organizationService.get(this.organizationId).canManageBilling;
try {
this.org = await this.organizationApiService.get(this.organizationId);
this.canUseApi = this.org.useApi;
} catch (e) {
this.logService.error(e);
}
});
this.loading = false;
}
async submit() {
try {
const request = new OrganizationUpdateRequest();
request.name = this.org.name;
request.businessName = this.org.businessName;
request.billingEmail = this.org.billingEmail;
// Backfill pub/priv key if necessary
if (!this.org.hasPublicAndPrivateKeys) {
const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId);
const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey);
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
}
this.formPromise = this.organizationApiService.save(this.organizationId, request);
await this.formPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("organizationUpdated")
);
} catch (e) {
this.logService.error(e);
}
}
async deleteOrganization() {
await this.modalService.openViewRef(
DeleteOrganizationComponent,
this.deleteModalRef,
(comp) => {
comp.organizationId = this.organizationId;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSuccess.subscribe(() => {
this.router.navigate(["/"]);
});
}
);
}
async purgeVault() {
await this.modalService.openViewRef(PurgeVaultComponent, this.purgeModalRef, (comp) => {
comp.organizationId = this.organizationId;
});
}
async viewApiKey() {
await this.modalService.openViewRef(ApiKeyComponent, this.apiKeyModalRef, (comp) => {
comp.keyType = "organization";
comp.entityId = this.organizationId;
comp.postKey = this.organizationApiService.getOrCreateApiKey.bind(
this.organizationApiService
);
comp.scope = "api.organization";
comp.grantType = "client_credentials";
comp.apiKeyTitle = "apiKey";
comp.apiKeyWarning = "apiKeyWarning";
comp.apiKeyDescription = "apiKeyDesc";
});
}
async rotateApiKey() {
await this.modalService.openViewRef(ApiKeyComponent, this.rotateApiKeyModalRef, (comp) => {
comp.keyType = "organization";
comp.isRotation = true;
comp.entityId = this.organizationId;
comp.postKey = this.organizationApiService.rotateApiKey.bind(this.organizationApiService);
comp.scope = "api.organization";
comp.grantType = "client_credentials";
comp.apiKeyTitle = "apiKey";
comp.apiKeyWarning = "apiKeyWarning";
comp.apiKeyDescription = "apiKeyRotateDesc";
});
}
}

View File

@@ -0,0 +1,61 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="deleteOrganizationTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
*ngIf="loaded"
>
<div class="modal-header">
<h1 class="modal-title" id="deleteOrganizationTitle">{{ "deleteOrganization" | i18n }}</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<app-callout type="warning">{{
"deletingOrganizationIsPermanentWarning" | i18n : organizationName
}}</app-callout>
<p id="organizationDeleteDescription">
<ng-container
*ngIf="
deleteOrganizationRequestType === 'InvalidFamiliesForEnterprise';
else regularDelete
"
>
{{ "orgCreatedSponsorshipInvalid" | i18n }}
</ng-container>
<ng-template #regularDelete>
<ng-container *ngIf="organizationContentSummary.totalItemCount > 0">
{{ "deletingOrganizationContentWarning" | i18n : organizationName }}
<ul>
<li *ngFor="let type of organizationContentSummary.itemCountByType">
{{ type.count }} {{ type.localizationKey | i18n }}
</li>
</ul>
{{ "deletingOrganizationActiveUserAccountsWarning" | i18n }}
</ng-container>
</ng-template>
</p>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-user-verification>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "deleteOrganization" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,131 @@
import { Component, EventEmitter, OnInit, Output } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Utils } from "@bitwarden/common/misc/utils";
import { Verification } from "@bitwarden/common/types/verification";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
class CountBasedLocalizationKey {
singular: string;
plural: string;
getKey(count: number) {
return count == 1 ? this.singular : this.plural;
}
constructor(singular: string, plural: string) {
this.singular = singular;
this.plural = plural;
}
}
class OrganizationContentSummaryItem {
count: number;
get localizationKey(): string {
return this.localizationKeyOptions.getKey(this.count);
}
private localizationKeyOptions: CountBasedLocalizationKey;
constructor(count: number, localizationKeyOptions: CountBasedLocalizationKey) {
this.count = count;
this.localizationKeyOptions = localizationKeyOptions;
}
}
class OrganizationContentSummary {
totalItemCount = 0;
itemCountByType: OrganizationContentSummaryItem[] = [];
}
@Component({
selector: "app-delete-organization",
templateUrl: "delete-organization.component.html",
})
export class DeleteOrganizationComponent implements OnInit {
organizationId: string;
loaded: boolean;
deleteOrganizationRequestType: "InvalidFamiliesForEnterprise" | "RegularDelete" = "RegularDelete";
organizationName: string;
organizationContentSummary: OrganizationContentSummary = new OrganizationContentSummary();
@Output() onSuccess: EventEmitter<void> = new EventEmitter();
masterPassword: Verification;
formPromise: Promise<void>;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private userVerificationService: UserVerificationService,
private logService: LogService,
private cipherService: CipherService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction
) {}
async ngOnInit(): Promise<void> {
await this.load();
}
async submit() {
try {
this.formPromise = this.userVerificationService
.buildRequest(this.masterPassword)
.then((request) => this.organizationApiService.delete(this.organizationId, request));
await this.formPromise;
this.platformUtilsService.showToast(
"success",
this.i18nService.t("organizationDeleted"),
this.i18nService.t("organizationDeletedDesc")
);
this.onSuccess.emit();
} catch (e) {
this.logService.error(e);
}
}
private async load() {
this.organizationName = (await this.organizationService.get(this.organizationId)).name;
this.organizationContentSummary = await this.buildOrganizationContentSummary();
this.loaded = true;
}
private async buildOrganizationContentSummary(): Promise<OrganizationContentSummary> {
const organizationContentSummary = new OrganizationContentSummary();
const organizationItems = (
await this.cipherService.getAllFromApiForOrganization(this.organizationId)
).filter((item) => item.deletedDate == null);
if (organizationItems.length < 1) {
return organizationContentSummary;
}
organizationContentSummary.totalItemCount = organizationItems.length;
for (const cipherType of Utils.iterateEnum(CipherType)) {
const count = this.getOrganizationItemCountByType(organizationItems, cipherType);
if (count > 0) {
organizationContentSummary.itemCountByType.push(
new OrganizationContentSummaryItem(
count,
this.getOrganizationItemLocalizationKeysByType(CipherType[cipherType])
)
);
}
}
return organizationContentSummary;
}
private getOrganizationItemCountByType(items: CipherView[], type: CipherType) {
return items.filter((item) => item.type == type).length;
}
private getOrganizationItemLocalizationKeysByType(type: string): CountBasedLocalizationKey {
return new CountBasedLocalizationKey(`type${type}`, `type${type}Plural`);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./organization-settings.module";
export { DeleteOrganizationComponent } from "./delete-organization.component";

View File

@@ -0,0 +1,80 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard";
import { OrganizationRedirectGuard } from "../../organizations/guards/org-redirect.guard";
import { PoliciesComponent } from "../../organizations/policies";
import { AccountComponent } from "./account.component";
import { SettingsComponent } from "./settings.component";
import { TwoFactorSetupComponent } from "./two-factor-setup.component";
const routes: Routes = [
{
path: "",
component: SettingsComponent,
canActivate: [OrganizationPermissionsGuard],
data: { organizationPermissions: canAccessSettingsTab },
children: [
{
path: "",
pathMatch: "full",
canActivate: [OrganizationRedirectGuard],
data: {
autoRedirectCallback: getSettingsRoute,
},
children: [], // This is required to make the auto redirect work,
},
{ path: "account", component: AccountComponent, data: { titleId: "organizationInfo" } },
{
path: "two-factor",
component: TwoFactorSetupComponent,
data: { titleId: "twoStepLogin" },
},
{
path: "policies",
component: PoliciesComponent,
canActivate: [OrganizationPermissionsGuard],
data: {
organizationPermissions: (org: Organization) => org.canManagePolicies,
titleId: "policies",
},
},
{
path: "tools",
loadChildren: () =>
import("../tools/import-export/org-import-export.module").then(
(m) => m.OrganizationImportExportModule
),
},
],
},
];
function getSettingsRoute(organization: Organization) {
if (organization.isOwner) {
return "account";
}
if (organization.canManagePolicies) {
return "policies";
}
if (organization.canAccessImportExport) {
return ["tools", "import"];
}
if (organization.canManageSso) {
return "sso";
}
if (organization.canManageScim) {
return "scim";
}
return undefined;
}
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class OrganizationSettingsRoutingModule {}

View File

@@ -0,0 +1,21 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { PoliciesModule } from "../../organizations/policies";
import { AccountComponent } from "./account.component";
import { DeleteOrganizationComponent } from "./delete-organization.component";
import { OrganizationSettingsRoutingModule } from "./organization-settings-routing.module";
import { SettingsComponent } from "./settings.component";
import { TwoFactorSetupComponent } from "./two-factor-setup.component";
@NgModule({
imports: [SharedModule, LooseComponentsModule, PoliciesModule, OrganizationSettingsRoutingModule],
declarations: [
SettingsComponent,
AccountComponent,
DeleteOrganizationComponent,
TwoFactorSetupComponent,
],
})
export class OrganizationSettingsModule {}

View File

@@ -0,0 +1,78 @@
<div class="container page-content">
<div class="row">
<div class="col-3">
<div class="card">
<div class="card-header">{{ "settings" | i18n }}</div>
<div class="list-group list-group-flush" *ngIf="organization$ | async as org">
<a
routerLink="account"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.isOwner"
>
{{ "organizationInfo" | i18n }}
</a>
<a
routerLink="policies"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canManagePolicies"
>
{{ "policies" | i18n }}
</a>
<a
routerLink="two-factor"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.use2fa && org.isOwner"
>
{{ "twoStepLogin" | i18n }}
</a>
<a
routerLink="tools/import"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canAccessImportExport"
>
{{ "importData" | i18n }}
</a>
<a
routerLink="tools/export"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canAccessImportExport"
>
{{ "exportVault" | i18n }}
</a>
<a
routerLink="domain-verification"
class="list-group-item"
routerLinkActive="active"
*ngIf="org?.canManageDomainVerification"
>
{{ "domainVerification" | i18n }}
</a>
<a
routerLink="sso"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canManageSso"
>
{{ "singleSignOn" | i18n }}
</a>
<a
routerLink="scim"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canManageScim"
>
{{ "scim" | i18n }}
</a>
</div>
</div>
</div>
<div class="col-9">
<router-outlet></router-outlet>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Observable, switchMap } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Component({
selector: "app-org-settings",
templateUrl: "settings.component.html",
})
export class SettingsComponent implements OnInit {
organization$: Observable<Organization>;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
this.organization$ = this.route.params.pipe(
switchMap((params) => this.organizationService.get$(params.organizationId))
);
}
}

View File

@@ -0,0 +1,64 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorDuoComponent } from "../../../../auth/settings/two-factor-duo.component";
import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../../auth/settings/two-factor-setup.component";
@Component({
selector: "app-two-factor-setup",
templateUrl: "../../../../auth/settings/two-factor-setup.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent {
tabbedHeader = false;
constructor(
apiService: ApiService,
modalService: ModalService,
messagingService: MessagingService,
policyService: PolicyService,
private route: ActivatedRoute,
stateService: StateService
) {
super(apiService, modalService, messagingService, policyService, stateService);
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
await super.ngOnInit();
});
}
async manage(type: TwoFactorProviderType) {
switch (type) {
case TwoFactorProviderType.OrganizationDuo: {
const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent);
duoComp.type = TwoFactorProviderType.OrganizationDuo;
duoComp.organizationId = this.organizationId;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
duoComp.onUpdated.subscribe((enabled: boolean) => {
this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo);
});
break;
}
default:
break;
}
}
protected getTwoFactorProviders() {
return this.apiService.getTwoFactorOrganizationProviders(this.organizationId);
}
protected filterProvider(type: TwoFactorProviderType) {
return type !== TwoFactorProviderType.OrganizationDuo;
}
}

View File

@@ -0,0 +1,156 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<div class="tw-flex">
<bit-form-field *ngIf="permissionMode == 'edit'" class="tw-mr-3 tw-shrink-0">
<bit-label>{{ "permission" | i18n }}</bit-label>
<!--
Built-in select height differs between browsers, this fix makes sure we match bit-multi-select height.
We might want to reconsider this fix when/if we implement
[CL-78] [Improvement] Completely restyled selects (https://bitwarden.atlassian.net/browse/CL-78)
-->
<select
class="tw-h-[35px]"
bitInput
[disabled]="disabled"
[(ngModel)]="initialPermission"
[ngModelOptions]="{ standalone: true }"
(blur)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
</bit-form-field>
<bit-form-field class="tw-grow">
<bit-label>{{ selectorLabelText }}</bit-label>
<bit-multi-select
class="tw-w-full"
[baseItems]="selectionList.deselectedItems"
[removeSelectedItems]="true"
[disabled]="disabled"
(onItemsConfirmed)="selectItems($event)"
(blur)="handleBlur()"
></bit-multi-select>
<bit-hint *ngIf="selectorHelpText">{{ selectorHelpText }}</bit-hint>
</bit-form-field>
</div>
<bit-table [formGroup]="formGroup">
<ng-container header>
<tr>
<th bitCell>{{ columnHeader }}</th>
<th bitCell id="permissionColHeading" *ngIf="permissionMode != 'hidden'">
<div class="tw-border tw-border-solid tw-border-transparent">
{{ "permission" | i18n }}
</div>
</th>
<th bitCell id="roleColHeading" *ngIf="showMemberRoles">{{ "role" | i18n }}</th>
<th bitCell id="groupColHeading" *ngIf="showGroupColumn">{{ "group" | i18n }}</th>
<th bitCell class="tw-w-20"></th>
</tr>
</ng-container>
<ng-template body formArrayName="items">
<tr
bitRow
*ngFor="let item of selectionList.selectedItems; let i = index"
[formGroupName]="i"
[ngClass]="{ 'tw-text-muted': item.readonly }"
>
<td bitCell [ngSwitch]="item.type">
<div class="tw-flex tw-items-center" *ngSwitchCase="itemType.Member">
<bit-avatar size="small" class="tw-mr-3" text="{{ item.labelName }}"></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
{{ item.labelName }}
<span *ngIf="$any(item).status == 0" bitBadge badgeType="secondary">
{{ "invited" | i18n }}
</span>
</div>
<div class="tw-text-xs tw-text-muted" *ngIf="$any(item).status != 0">
{{ $any(item).email }}
</div>
</div>
</div>
<div class="tw-flex tw-items-center" *ngSwitchDefault>
<i
class="bwi tw-mr-3 tw-px-0.5 tw-text-2xl"
[ngClass]="item.icon || itemIcon(item)"
aria-hidden="true"
></i>
<span>{{ item.labelName }}</span>
</div>
</td>
<td bitCell *ngIf="permissionMode != 'hidden'">
<ng-container *ngIf="canEditItemPermission(item); else readOnlyPerm">
<label class="sr-only" [for]="'permission' + i"
>{{ item.labelName }} {{ "permission" | i18n }}</label
>
<div class="tw-relative tw-inline-block">
<select
bitInput
class="tw-apperance-none -tw-ml-3 tw-max-w-40 tw-appearance-none tw-overflow-ellipsis !tw-rounded tw-border-transparent !tw-bg-transparent tw-pr-6 tw-font-bold hover:tw-border-primary-700"
formControlName="permission"
[id]="'permission' + i"
(blur)="handleBlur()"
>
<option *ngFor="let p of permissionList" [value]="p.perm">
{{ p.labelId | i18n }}
</option>
</select>
<label
[for]="'permission' + i"
class="tw-absolute tw-inset-y-0 tw-right-4 tw-mb-0 tw-flex tw-items-center"
>
<i class="bwi bwi-sm bwi-angle-down tw-leading-[0]"></i>
</label>
</div>
</ng-container>
<ng-template #readOnlyPerm>
<div
*ngIf="item.accessAllItems"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-border tw-border-solid tw-border-transparent tw-font-bold tw-text-muted"
[appA11yTitle]="accessAllLabelId(item) | i18n"
>
{{ "canEdit" | i18n }}
<i class="bwi bwi-filter tw-ml-1" aria-hidden="true"></i>
</div>
<div
*ngIf="item.readonly"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n"
>
{{ permissionLabelId(item.readonlyPermission) | i18n }}
</div>
</ng-template>
</td>
<td bitCell *ngIf="showMemberRoles">
{{ $any(item).role | userType : "-" }}
</td>
<td bitCell *ngIf="showGroupColumn">
{{ $any(item).viaGroupName ?? "-" }}
</td>
<td bitCell class="tw-text-right">
<button
*ngIf="!item.readonly"
type="button"
bitIconButton="bwi-close"
buttonType="muted"
appA11yTitle="{{ 'remove' | i18n }} {{ item.labelName }}"
[disabled]="disabled"
(click)="selectionList.deselectItem(item.id); handleBlur()"
></button>
</td>
</tr>
<tr *ngIf="selectionList.selectedItems.length == 0">
<td bitCell>{{ emptySelectionText }}</td>
</tr>
</ng-template>
</bit-table>

View File

@@ -0,0 +1,250 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import {
AvatarModule,
BadgeModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { PreloadedEnglishI18nModule } from "../../../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
import { AccessItemType, CollectionPermission } from "./access-selector.models";
import { UserTypePipe } from "./user-type.pipe";
/**
* Helper class that makes it easier to test the AccessSelectorComponent by
* exposing some protected methods/properties
*/
class TestableAccessSelectorComponent extends AccessSelectorComponent {
selectItems(items: SelectItemView[]) {
super.selectItems(items);
}
deselectItem(id: string) {
this.selectionList.deselectItem(id);
}
/**
* Helper used to simulate a user selecting a new permission for a table row
* @param index - "Row" index
* @param perm - The new permission value
*/
changeSelectedItemPerm(index: number, perm: CollectionPermission) {
this.selectionList.formArray.at(index).patchValue({
permission: perm,
});
}
}
describe("AccessSelectorComponent", () => {
let component: TestableAccessSelectorComponent;
let fixture: ComponentFixture<TestableAccessSelectorComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
ButtonModule,
FormFieldModule,
AvatarModule,
BadgeModule,
ReactiveFormsModule,
FormsModule,
TabsModule,
TableModule,
PreloadedEnglishI18nModule,
JslibModule,
IconButtonModule,
],
declarations: [TestableAccessSelectorComponent, UserTypePipe],
providers: [],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestableAccessSelectorComponent);
component = fixture.componentInstance;
component.emptySelectionText = "Nothing selected";
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("item selection", () => {
beforeEach(() => {
component.items = [
{
id: "123",
type: AccessItemType.Group,
labelName: "Group 1",
listName: "Group 1",
},
];
fixture.detectChanges();
});
it("should show the empty row when nothing is selected", () => {
const emptyTableCell = fixture.nativeElement.querySelector("tbody tr td");
expect(emptyTableCell?.textContent).toEqual("Nothing selected");
});
it("should show one row when one value is selected", () => {
component.selectItems([{ id: "123" } as any]);
fixture.detectChanges();
const firstColSpan = fixture.nativeElement.querySelector("tbody tr td span");
expect(firstColSpan.textContent).toEqual("Group 1");
});
it("should emit value change when a value is selected", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Edit;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
});
it("should emit value change when a row is modified", () => {
// Arrange
const mockChange = jest.fn();
component.permissionMode = PermissionMode.Edit;
component.selectItems([{ id: "123" } as any]);
component.registerOnChange(mockChange); // Register change listener after setup
// Act
component.changeSelectedItemPerm(0, CollectionPermission.Edit);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).toHaveProperty(
"[0].permission",
CollectionPermission.Edit
);
});
it("should emit value change when a row is removed", () => {
// Arrange
const mockChange = jest.fn();
component.permissionMode = PermissionMode.Edit;
component.selectItems([{ id: "123" } as any]);
component.registerOnChange(mockChange); // Register change listener after setup
// Act
component.deselectItem("123");
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0].length).toEqual(0);
});
it("should emit permission values when in edit mode", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Edit;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].permission");
});
it("should not emit permission values when not in edit mode", () => {
// Arrange
const mockChange = jest.fn();
component.registerOnChange(mockChange);
component.permissionMode = PermissionMode.Hidden;
// Act
component.selectItems([{ id: "123" } as any]);
// Assert
expect(mockChange.mock.calls.length).toEqual(1);
expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
expect(mockChange.mock.lastCall[0]).not.toHaveProperty("[0].permission");
});
});
describe("column rendering", () => {
beforeEach(() => {
component.items = [
{
id: "g1",
type: AccessItemType.Group,
labelName: "Group 1",
listName: "Group 1",
},
{
id: "m1",
type: AccessItemType.Member,
labelName: "Member 1",
listName: "Member 1 (member1@email.com)",
email: "member1@email.com",
role: OrganizationUserType.Manager,
status: OrganizationUserStatusType.Confirmed,
},
];
fixture.detectChanges();
});
test.each([true, false])("should show the role column when enabled", (columnEnabled) => {
// Act
component.showMemberRoles = columnEnabled;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#roleColHeading");
expect(!!colHeading).toEqual(columnEnabled);
});
test.each([true, false])("should show the group column when enabled", (columnEnabled) => {
// Act
component.showGroupColumn = columnEnabled;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#groupColHeading");
expect(!!colHeading).toEqual(columnEnabled);
});
const permissionColumnCases = [
[PermissionMode.Hidden, false],
[PermissionMode.Edit, true],
[PermissionMode.Readonly, true],
];
test.each(permissionColumnCases)(
"should show the permission column when enabled",
(mode: PermissionMode, shouldShowColumn) => {
// Act
component.permissionMode = mode;
fixture.detectChanges();
// Assert
const colHeading = fixture.nativeElement.querySelector("#permissionColHeading");
expect(!!colHeading).toEqual(shouldShowColumn);
}
);
});
});

View File

@@ -0,0 +1,295 @@
import { Component, forwardRef, Input, OnDestroy, OnInit } from "@angular/core";
import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
CollectionPermission,
} from "./access-selector.models";
export enum PermissionMode {
/**
* No permission controls or column present. No permission values are emitted.
*/
Hidden = "hidden",
/**
* No permission controls. Column rendered an if available on an item. No permission values are emitted
*/
Readonly = "readonly",
/**
* Permission Controls and column present. Permission values are emitted.
*/
Edit = "edit",
}
@Component({
selector: "bit-access-selector",
templateUrl: "access-selector.component.html",
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AccessSelectorComponent),
multi: true,
},
],
})
export class AccessSelectorComponent implements ControlValueAccessor, OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private notifyOnChange: (v: unknown) => void;
private notifyOnTouch: () => void;
private pauseChangeNotification: boolean;
/**
* The internal selection list that tracks the value of this form control / component.
* It's responsible for keeping items sorted and synced with the rendered form controls
* @protected
*/
protected selectionList = new FormSelectionList<AccessItemView, AccessItemValue>((item) => {
const permissionControl = this.formBuilder.control(this.initialPermission);
const fg = this.formBuilder.group({
id: item.id,
type: item.type,
permission: permissionControl,
});
// Disable entire row form group if readonly
if (item.readonly) {
fg.disable();
}
// Disable permission control if accessAllItems is enabled
if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) {
permissionControl.disable();
}
return fg;
}, this._itemComparator.bind(this));
/**
* Internal form group for this component.
* @protected
*/
protected formGroup = this.formBuilder.group({
items: this.selectionList.formArray,
});
protected itemType = AccessItemType;
protected permissionList = [
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
];
protected initialPermission = CollectionPermission.View;
disabled: boolean;
/**
* List of all selectable items that. Sorted internally.
*/
@Input()
get items(): AccessItemView[] {
return this.selectionList.allItems;
}
set items(val: AccessItemView[]) {
const selected = (this.selectionList.formArray.getRawValue() ?? []).concat(
val.filter((m) => m.readonly)
);
this.selectionList.populateItems(
val.map((m) => {
m.icon = m.icon ?? this.itemIcon(m); // Ensure an icon is set
return m;
}),
selected
);
}
/**
* Permission mode that controls if the permission form controls and column should be present.
*/
@Input()
get permissionMode(): PermissionMode {
return this._permissionMode;
}
set permissionMode(value: PermissionMode) {
this._permissionMode = value;
// Toggle any internal permission controls
for (const control of this.selectionList.formArray.controls) {
if (value == PermissionMode.Edit) {
control.get("permission").enable();
} else {
control.get("permission").disable();
}
}
}
private _permissionMode: PermissionMode = PermissionMode.Hidden;
/**
* Column header for the selected items table
*/
@Input() columnHeader: string;
/**
* Label used for the ng selector
*/
@Input() selectorLabelText: string;
/**
* Helper text displayed under the ng selector
*/
@Input() selectorHelpText: string;
/**
* Text that is shown in the table when no items are selected
*/
@Input() emptySelectionText: string;
/**
* Flag for if the member roles column should be present
*/
@Input() showMemberRoles: boolean;
/**
* Flag for if the group column should be present
*/
@Input() showGroupColumn: boolean;
constructor(
private readonly formBuilder: FormBuilder,
private readonly i18nService: I18nService
) {}
/** Required for NG_VALUE_ACCESSOR */
registerOnChange(fn: any): void {
this.notifyOnChange = fn;
}
/** Required for NG_VALUE_ACCESSOR */
registerOnTouched(fn: any): void {
this.notifyOnTouch = fn;
}
/** Required for NG_VALUE_ACCESSOR */
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
// Keep the internal FormGroup in sync
if (this.disabled) {
this.formGroup.disable();
} else {
this.formGroup.enable();
}
}
/** Required for NG_VALUE_ACCESSOR */
writeValue(selectedItems: AccessItemValue[]): void {
// Modifying the selection list, mistakenly fires valueChanges in the
// internal form array, so we need to know to pause external notification
this.pauseChangeNotification = true;
// Always clear the internal selection list on a new value
this.selectionList.deselectAll();
// We need to also select any read only items to appear in the table
this.selectionList.selectItems(this.items.filter((m) => m.readonly).map((m) => m.id));
// If the new value is null, then we're done
if (selectedItems == null) {
this.pauseChangeNotification = false;
return;
}
// Unable to handle other value types, throw
if (!Array.isArray(selectedItems)) {
throw new Error("The access selector component only supports Array form values!");
}
// Iterate and internally select each item
for (const value of selectedItems) {
this.selectionList.selectItem(value.id, value);
}
this.pauseChangeNotification = false;
}
ngOnInit() {
// Watch the internal formArray for changes and propagate them
this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
if (!this.notifyOnChange || this.pauseChangeNotification) {
return;
}
// Disabled form arrays emit values for disabled controls, we override this to emit an empty array to avoid
// emitting values for disabled controls that are "readonly" in the table
if (this.selectionList.formArray.disabled) {
this.notifyOnChange([]);
return;
}
this.notifyOnChange(v);
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
protected handleBlur() {
if (!this.notifyOnTouch) {
return;
}
this.notifyOnTouch();
}
protected selectItems(items: SelectItemView[]) {
this.pauseChangeNotification = true;
this.selectionList.selectItems(items.map((i) => i.id));
this.pauseChangeNotification = false;
if (this.notifyOnChange != undefined) {
this.notifyOnChange(this.selectionList.formArray.value);
}
}
protected itemIcon(item: AccessItemView) {
switch (item.type) {
case AccessItemType.Collection:
return "bwi-collection";
case AccessItemType.Group:
return "bwi-users";
case AccessItemType.Member:
return "bwi-user";
}
}
protected permissionLabelId(perm: CollectionPermission) {
return this.permissionList.find((p) => p.perm == perm)?.labelId;
}
protected accessAllLabelId(item: AccessItemView) {
return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll";
}
protected canEditItemPermission(item: AccessItemView) {
return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems;
}
private _itemComparator(a: AccessItemView, b: AccessItemView) {
return (
a.type - b.type ||
this.i18nService.collator.compare(a.listName, b.listName) ||
this.i18nService.collator.compare(a.labelName, b.labelName) ||
Number(b.readonly) - Number(a.readonly)
);
}
}

View File

@@ -0,0 +1,107 @@
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import { SelectItemView } from "@bitwarden/components";
import { CollectionAccessSelectionView } from "../../../../../organizations/core";
/**
* Permission options that replace/correspond with readOnly and hidePassword server fields.
*/
export enum CollectionPermission {
View = "view",
ViewExceptPass = "viewExceptPass",
Edit = "edit",
EditExceptPass = "editExceptPass",
}
export enum AccessItemType {
Collection,
Group,
Member,
}
/**
* A "generic" type that describes an item that can be selected from a
* ng-select list and have its collection permission modified.
*
* Currently, it supports Collections, Groups, and Members. Members require some additional
* details to render in the AccessSelectorComponent so their type is defined separately
* and then joined back with the base type.
*
*/
export type AccessItemView =
| SelectItemView & {
/**
* Flag that this group/member can access all items.
* This will disable the permission editor for this item.
*/
accessAllItems?: boolean;
/**
* Flag that this item cannot be modified.
* This will disable the permission editor and will keep
* the item always selected.
*/
readonly?: boolean;
/**
* Optional permission that will be rendered for this
* item if it set to readonly.
*/
readonlyPermission?: CollectionPermission;
} & (
| {
type: AccessItemType.Collection;
viaGroupName?: string;
}
| {
type: AccessItemType.Group;
}
| {
type: AccessItemType.Member; // Members have a few extra details required to display, so they're added here
email: string;
role: OrganizationUserType;
status: OrganizationUserStatusType;
}
);
/**
* A type that is emitted as a value for the ngControl
*/
export type AccessItemValue = {
id: string;
permission?: CollectionPermission;
type: AccessItemType;
};
/**
* Converts the CollectionAccessSelectionView interface to one of the new CollectionPermission values
* for the dropdown in the AccessSelectorComponent
* @param value
*/
export const convertToPermission = (value: CollectionAccessSelectionView) => {
if (value.readOnly) {
return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
} else {
return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit;
}
};
/**
* Converts an AccessItemValue back into a CollectionAccessView class using the CollectionPermission
* to determine the values for `readOnly` and `hidePassword`
* @param value
*/
export const convertToSelectionView = (value: AccessItemValue) => {
return new CollectionAccessSelectionView({
id: value.id,
readOnly: readOnly(value.permission),
hidePasswords: hidePassword(value.permission),
});
};
const readOnly = (perm: CollectionPermission) =>
[CollectionPermission.View, CollectionPermission.ViewExceptPass].includes(perm);
const hidePassword = (perm: CollectionPermission) =>
[CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../../../shared";
import { AccessSelectorComponent } from "./access-selector.component";
import { UserTypePipe } from "./user-type.pipe";
@NgModule({
imports: [SharedModule],
declarations: [AccessSelectorComponent, UserTypePipe],
exports: [AccessSelectorComponent],
})
export class AccessSelectorModule {}

View File

@@ -0,0 +1,371 @@
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums/organization-user-status-type";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
import {
AvatarModule,
BadgeModule,
ButtonModule,
DialogModule,
FormFieldModule,
IconButtonModule,
TableModule,
TabsModule,
} from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../../../tests/preloaded-english-i18n.module";
import { AccessSelectorComponent } from "./access-selector.component";
import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";
import { UserTypePipe } from "./user-type.pipe";
export default {
title: "Web/Organizations/Access Selector",
decorators: [
moduleMetadata({
declarations: [AccessSelectorComponent, UserTypePipe],
imports: [
DialogModule,
ButtonModule,
FormFieldModule,
AvatarModule,
BadgeModule,
ReactiveFormsModule,
FormsModule,
TabsModule,
TableModule,
PreloadedEnglishI18nModule,
JslibModule,
IconButtonModule,
],
providers: [],
}),
],
parameters: {},
argTypes: {
formObj: { table: { disable: true } },
},
} as Meta;
const actionsData = {
onValueChanged: action("onValueChanged"),
onSubmit: action("onSubmit"),
};
/**
* Factory to help build semi-realistic looking items
* @param n - The number of items to build
* @param type - Which type to build
*/
const itemsFactory = (n: number, type: AccessItemType) => {
return [...Array(n)].map((_: unknown, id: number) => {
const item: AccessItemView = {
id: id.toString(),
type: type,
} as AccessItemView;
switch (item.type) {
case AccessItemType.Collection:
item.labelName = item.listName = `Collection ${id}`;
item.id = item.id + "c";
item.parentGrouping = "Collection Parent Group " + ((id % 2) + 1);
break;
case AccessItemType.Group:
item.labelName = item.listName = `Group ${id}`;
item.id = item.id + "g";
break;
case AccessItemType.Member:
item.id = item.id + "m";
item.email = `member${id}@email.com`;
item.status = id % 3 == 0 ? 0 : 2;
item.labelName = item.status == 2 ? `Member ${id}` : item.email;
item.listName = item.status == 2 ? `${item.labelName} (${item.email})` : item.email;
item.role = id % 5;
break;
}
return item;
});
};
const sampleMembers = itemsFactory(10, AccessItemType.Member);
const sampleGroups = itemsFactory(6, AccessItemType.Group);
const StandaloneAccessSelectorTemplate: Story<AccessSelectorComponent> = (
args: AccessSelectorComponent
) => ({
props: {
items: [],
valueChanged: actionsData.onValueChanged,
initialValue: [],
...args,
},
template: `
<bit-access-selector
(ngModelChange)="valueChanged($event)"
[ngModel]="initialValue"
[items]="items"
[disabled]="disabled"
[columnHeader]="columnHeader"
[showGroupColumn]="showGroupColumn"
[selectorLabelText]="selectorLabelText"
[selectorHelpText]="selectorHelpText"
[emptySelectionText]="emptySelectionText"
[permissionMode]="permissionMode"
[showMemberRoles]="showMemberRoles"
></bit-access-selector>
`,
});
const DialogAccessSelectorTemplate: Story<AccessSelectorComponent> = (
args: AccessSelectorComponent
) => ({
props: {
items: [],
valueChanged: actionsData.onValueChanged,
initialValue: [],
...args,
},
template: `
<bit-dialog [dialogSize]="dialogSize" [disablePadding]="disablePadding">
<span bitDialogTitle>Access selector</span>
<span bitDialogContent>
<bit-access-selector
(ngModelChange)="valueChanged($event)"
[ngModel]="initialValue"
[items]="items"
[disabled]="disabled"
[columnHeader]="columnHeader"
[showGroupColumn]="showGroupColumn"
[selectorLabelText]="selectorLabelText"
[selectorHelpText]="selectorHelpText"
[emptySelectionText]="emptySelectionText"
[permissionMode]="permissionMode"
[showMemberRoles]="showMemberRoles"
></bit-access-selector>
</span>
<div bitDialogFooter class="tw-flex tw-items-center tw-flex-row tw-gap-2">
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
<button
class="tw-ml-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
title="Delete"
aria-label="Delete"></button>
</div>
</bit-dialog>
`,
});
const dialogAccessItems = itemsFactory(10, AccessItemType.Collection);
const memberCollectionAccessItems = itemsFactory(3, AccessItemType.Collection).concat([
{
id: "c1-group1",
type: AccessItemType.Collection,
labelName: "Collection 1",
listName: "Collection 1",
viaGroupName: "Group 1",
readonlyPermission: CollectionPermission.View,
readonly: true,
},
{
id: "c1-group2",
type: AccessItemType.Collection,
labelName: "Collection 1",
listName: "Collection 1",
viaGroupName: "Group 2",
readonlyPermission: CollectionPermission.ViewExceptPass,
readonly: true,
},
]);
export const Dialog = DialogAccessSelectorTemplate.bind({});
Dialog.args = {
permissionMode: "edit",
showMemberRoles: false,
showGroupColumn: true,
columnHeader: "Collection",
selectorLabelText: "Select Collections",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No collections added",
disabled: false,
initialValue: [],
items: dialogAccessItems,
};
Dialog.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for modifying the collections a member has access to inside of a dialog.
`,
},
},
};
export const MemberCollectionAccess = StandaloneAccessSelectorTemplate.bind({});
MemberCollectionAccess.args = {
permissionMode: "edit",
showMemberRoles: false,
showGroupColumn: true,
columnHeader: "Collection",
selectorLabelText: "Select Collections",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No collections added",
disabled: false,
initialValue: [],
items: memberCollectionAccessItems,
};
MemberCollectionAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for modifying the collections a member has access to.
Includes examples of a readonly group and member that cannot be edited.
`,
},
},
};
export const MemberGroupAccess = StandaloneAccessSelectorTemplate.bind({});
MemberGroupAccess.args = {
permissionMode: "readonly",
showMemberRoles: false,
columnHeader: "Groups",
selectorLabelText: "Select Groups",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No groups added",
disabled: false,
initialValue: [{ id: "3g" }, { id: "0g" }],
items: itemsFactory(4, AccessItemType.Group).concat([
{
id: "admin",
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
},
]),
};
MemberGroupAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which groups an individual member belongs too.
`,
},
},
};
export const GroupMembersAccess = StandaloneAccessSelectorTemplate.bind({});
GroupMembersAccess.args = {
permissionMode: "hidden",
showMemberRoles: true,
columnHeader: "Members",
selectorLabelText: "Select Members",
selectorHelpText: "Some helper text describing what this does",
emptySelectionText: "No members added",
disabled: false,
initialValue: [{ id: "2m" }, { id: "0m" }],
items: sampleMembers,
};
GroupMembersAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which members belong to an specific group.
`,
},
},
};
export const CollectionAccess = StandaloneAccessSelectorTemplate.bind({});
CollectionAccess.args = {
permissionMode: "edit",
showMemberRoles: false,
columnHeader: "Groups/Members",
selectorLabelText: "Select groups and members",
selectorHelpText:
"Permissions set for a member will replace permissions set by that member's group",
emptySelectionText: "No members or groups added",
disabled: false,
initialValue: [
{ id: "3g", permission: CollectionPermission.EditExceptPass },
{ id: "0m", permission: CollectionPermission.View },
],
items: sampleGroups.concat(sampleMembers).concat([
{
id: "admin-group",
type: AccessItemType.Group,
listName: "Admin Group",
labelName: "Admin Group",
accessAllItems: true,
readonly: true,
},
{
id: "admin-member",
type: AccessItemType.Member,
listName: "Admin Member (admin@email.com)",
labelName: "Admin Member",
status: OrganizationUserStatusType.Confirmed,
role: OrganizationUserType.Admin,
email: "admin@email.com",
accessAllItems: true,
readonly: true,
},
]),
};
GroupMembersAccess.story = {
parameters: {
docs: {
storyDescription: `
Example of an access selector for selecting which members/groups have access to a specific collection.
`,
},
},
};
const fb = new FormBuilder();
const ReactiveFormAccessSelectorTemplate: Story<AccessSelectorComponent> = (
args: AccessSelectorComponent
) => ({
props: {
items: [],
onSubmit: actionsData.onSubmit,
...args,
},
template: `
<form [formGroup]="formObj" (ngSubmit)="onSubmit(formObj.controls.formItems.value)">
<bit-access-selector
formControlName="formItems"
[items]="items"
[columnHeader]="columnHeader"
[selectorLabelText]="selectorLabelText"
[selectorHelpText]="selectorHelpText"
[emptySelectionText]="emptySelectionText"
[permissionMode]="permissionMode"
[showMemberRoles]="showMemberRoles"
></bit-access-selector>
<button type="submit" bitButton buttonType="primary" class="tw-mt-5">Submit</button>
</form>
`,
});
export const ReactiveForm = ReactiveFormAccessSelectorTemplate.bind({});
ReactiveForm.args = {
formObj: fb.group({ formItems: [[{ id: "1g" }]] }),
permissionMode: "edit",
showMemberRoles: false,
columnHeader: "Groups/Members",
selectorLabelText: "Select groups and members",
selectorHelpText:
"Permissions set for a member will replace permissions set by that member's group",
emptySelectionText: "No members or groups added",
items: sampleGroups.concat(sampleMembers),
};

View File

@@ -0,0 +1,3 @@
export * from "./access-selector.component";
export * from "./access-selector.module";
export * from "./access-selector.models";

View File

@@ -0,0 +1,29 @@
import { Pipe, PipeTransform } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums/organization-user-type";
@Pipe({
name: "userType",
})
export class UserTypePipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserType, unknownText?: string): string {
if (value == null) {
return unknownText ?? this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserType.Owner:
return this.i18nService.t("owner");
case OrganizationUserType.Admin:
return this.i18nService.t("admin");
case OrganizationUserType.User:
return this.i18nService.t("user");
case OrganizationUserType.Manager:
return this.i18nService.t("manager");
case OrganizationUserType.Custom:
return this.i18nService.t("custom");
}
}
}

View File

@@ -0,0 +1,103 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [disablePadding]="!loading">
<span bitDialogTitle>
<ng-container *ngIf="editMode">
{{ "editCollection" | i18n }}
<span class="tw-text-sm tw-normal-case tw-text-muted" *ngIf="!loading">{{
collection.name
}}</span>
</ng-container>
<ng-container *ngIf="!editMode">
{{ "newCollection" | i18n }}
</ng-container>
</span>
<div bitDialogContent>
<ng-container *ngIf="loading" #spinner>
<i class="bwi bwi-spinner bwi-lg bwi-spin" aria-hidden="true"></i>
</ng-container>
<bit-tab-group *ngIf="!loading" [(selectedIndex)]="tabIndex">
<bit-tab label="{{ 'collectionInfo' | i18n }}">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input bitInput appAutofocus formControlName="name" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "nestCollectionUnder" | i18n }}</bit-label>
<bit-select bitInput formControlName="parent">
<bit-option [value]="undefined" [label]="'noCollection' | i18n"> </bit-option>
<bit-option
*ngIf="deletedParentName"
disabled
icon="bwi-collection"
[value]="deletedParentName"
label="{{ deletedParentName }} ({{ 'deleted' | i18n }})"
>
</bit-option>
<bit-option
*ngFor="let collection of nestOptions"
icon="bwi-collection"
[value]="collection.name"
[label]="collection.name"
>
</bit-option>
</bit-select>
</bit-form-field>
</bit-tab>
<bit-tab label="{{ 'access' | i18n }}">
<bit-access-selector
*ngIf="organization.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'groupAndMemberColumnHeader' | i18n"
[selectorLabelText]="'selectGroupsAndMembers' | i18n"
[selectorHelpText]="'userPermissionOverrideHelper' | i18n"
[emptySelectionText]="'noMembersOrGroupsAdded' | i18n"
></bit-access-selector>
<bit-access-selector
*ngIf="!organization.useGroups"
[permissionMode]="PermissionMode.Edit"
formControlName="access"
[items]="accessItems"
[columnHeader]="'memberColumnHeader' | i18n"
[selectorLabelText]="'selectMembers' | i18n"
[emptySelectionText]="'noMembersAdded' | i18n"
></bit-access-selector>
</bit-tab>
</bit-tab-group>
</div>
<div bitDialogFooter class="tw-flex tw-flex-row tw-gap-2">
<button type="submit" bitButton bitFormButton buttonType="primary" [disabled]="loading">
{{ "save" | i18n }}
</button>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
(click)="cancel()"
[disabled]="loading"
>
{{ "cancel" | i18n }}
</button>
<button
*ngIf="editMode && organization?.canDeleteAssignedCollections"
type="button"
bitIconButton="bwi-trash"
buttonType="danger"
class="tw-ml-auto"
bitFormButton
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
[disabled]="loading"
></button>
</div>
</bit-dialog>
</form>

View File

@@ -0,0 +1,302 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { combineLatest, of, shareReplay, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/common/abstractions/organization-user/responses";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view";
import { BitValidators, DialogService } from "@bitwarden/components";
import {
CollectionAdminService,
CollectionAdminView,
GroupService,
GroupView,
} from "../../../../../organizations/core";
import {
AccessItemType,
AccessItemValue,
AccessItemView,
convertToPermission,
convertToSelectionView,
PermissionMode,
} from "../access-selector";
export enum CollectionDialogTabType {
Info = 0,
Access = 1,
}
export interface CollectionDialogParams {
collectionId?: string;
organizationId: string;
initialTab?: CollectionDialogTabType;
parentCollectionId?: string;
}
export enum CollectionDialogResult {
Saved = "saved",
Canceled = "canceled",
Deleted = "deleted",
}
@Component({
selector: "app-collection-dialog",
templateUrl: "collection-dialog.component.html",
})
export class CollectionDialogComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected tabIndex: CollectionDialogTabType;
protected loading = true;
protected organization?: Organization;
protected collection?: CollectionView;
protected nestOptions: CollectionView[] = [];
protected accessItems: AccessItemView[] = [];
protected deletedParentName: string | undefined;
protected formGroup = this.formBuilder.group({
name: ["", [Validators.required, BitValidators.forbiddenCharacters(["/"])]],
externalId: "",
parent: undefined as string | undefined,
access: [[] as AccessItemValue[]],
});
protected PermissionMode = PermissionMode;
constructor(
@Inject(DIALOG_DATA) private params: CollectionDialogParams,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<CollectionDialogResult>,
private organizationService: OrganizationService,
private groupService: GroupService,
private collectionService: CollectionAdminService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private organizationUserService: OrganizationUserService
) {
this.tabIndex = params.initialTab ?? CollectionDialogTabType.Info;
}
ngOnInit() {
const organization$ = of(this.organizationService.get(this.params.organizationId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 })
);
const groups$ = organization$.pipe(
switchMap((organization) => {
if (!organization.useGroups) {
return of([] as GroupView[]);
}
return this.groupService.getAll(this.params.organizationId);
})
);
combineLatest({
organization: organization$,
collections: this.collectionService.getAll(this.params.organizationId),
collectionDetails: this.params.collectionId
? this.collectionService.get(this.params.organizationId, this.params.collectionId)
: of(null),
groups: groups$,
users: this.organizationUserService.getAllUsers(this.params.organizationId),
})
.pipe(takeUntil(this.destroy$))
.subscribe(({ organization, collections, collectionDetails, groups, users }) => {
this.organization = organization;
this.accessItems = [].concat(
groups.map(mapGroupToAccessItemView),
users.data.map(mapUserToAccessItemView)
);
if (this.params.collectionId) {
this.collection = collections.find((c) => c.id === this.collectionId);
this.nestOptions = collections.filter((c) => c.id !== this.collectionId);
if (!this.collection) {
throw new Error("Could not find collection to edit.");
}
const { name, parent } = parseName(this.collection);
if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) {
this.deletedParentName = parent;
}
const accessSelections = mapToAccessSelections(collectionDetails);
this.formGroup.patchValue({
name,
externalId: this.collection.externalId,
parent,
access: accessSelections,
});
} else {
this.nestOptions = collections;
const parent = collections.find((c) => c.id === this.params.parentCollectionId);
this.formGroup.patchValue({ parent: parent?.name ?? undefined });
}
this.loading = false;
});
}
protected get collectionId() {
return this.params.collectionId;
}
protected get editMode() {
return this.params.collectionId != undefined;
}
protected async cancel() {
this.close(CollectionDialogResult.Canceled);
}
protected submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
if (this.tabIndex === CollectionDialogTabType.Access) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("fieldOnTabRequiresAttention", this.i18nService.t("collectionInfo"))
);
}
return;
}
const collectionView = new CollectionAdminView();
collectionView.id = this.params.collectionId;
collectionView.organizationId = this.params.organizationId;
collectionView.externalId = this.formGroup.controls.externalId.value;
collectionView.groups = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Group)
.map(convertToSelectionView);
collectionView.users = this.formGroup.controls.access.value
.filter((v) => v.type === AccessItemType.Member)
.map(convertToSelectionView);
const parent = this.formGroup.controls.parent.value;
if (parent) {
collectionView.name = `${parent}/${this.formGroup.controls.name.value}`;
} else {
collectionView.name = this.formGroup.controls.name.value;
}
await this.collectionService.save(collectionView);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t(
this.editMode ? "editedCollectionId" : "createdCollectionId",
collectionView.name
)
);
this.close(CollectionDialogResult.Saved);
};
protected delete = async () => {
const confirmed = await this.platformUtilsService.showDialog(
this.i18nService.t("deleteCollectionConfirmation"),
this.collection?.name,
this.i18nService.t("yes"),
this.i18nService.t("no"),
"warning"
);
if (!confirmed && this.params.collectionId) {
return false;
}
await this.collectionService.delete(this.params.organizationId, this.params.collectionId);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("deletedCollectionId", this.collection?.name)
);
this.close(CollectionDialogResult.Deleted);
};
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private close(result: CollectionDialogResult) {
this.dialogRef.close(result);
}
}
function parseName(collection: CollectionView) {
const nameParts = collection.name?.split("/");
const name = nameParts[nameParts.length - 1];
const parent = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : undefined;
return { name, parent };
}
function mapGroupToAccessItemView(group: GroupView): AccessItemView {
return {
id: group.id,
type: AccessItemType.Group,
listName: group.name,
labelName: group.name,
accessAllItems: group.accessAll,
readonly: group.accessAll,
};
}
// TODO: Use view when user apis are migrated to a service
function mapUserToAccessItemView(user: OrganizationUserUserDetailsResponse): AccessItemView {
return {
id: user.id,
type: AccessItemType.Member,
email: user.email,
role: user.type,
listName: user.name?.length > 0 ? `${user.name} (${user.email})` : user.email,
labelName: user.name ?? user.email,
status: user.status,
accessAllItems: user.accessAll,
readonly: user.accessAll,
};
}
function mapToAccessSelections(collectionDetails: CollectionAdminView): AccessItemValue[] {
if (collectionDetails == undefined) {
return [];
}
return [].concat(
collectionDetails.groups.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Group,
permission: convertToPermission(selection),
})),
collectionDetails.users.map<AccessItemValue>((selection) => ({
id: selection.id,
type: AccessItemType.Member,
permission: convertToPermission(selection),
}))
);
}
/**
* Strongly typed helper to open a CollectionDialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export function openCollectionDialog(
dialogService: DialogService,
config: DialogConfig<CollectionDialogParams>
) {
return dialogService.open<CollectionDialogResult, CollectionDialogParams>(
CollectionDialogComponent,
config
);
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { SelectModule } from "@bitwarden/components";
import { AccessSelectorModule } from "../../../../../admin-console/organizations/shared/components/access-selector/access-selector.module";
import { SharedModule } from "../../../../../shared";
import { CollectionDialogComponent } from "./collection-dialog.component";
@NgModule({
imports: [SharedModule, AccessSelectorModule, SelectModule],
declarations: [CollectionDialogComponent],
exports: [CollectionDialogComponent],
})
export class CollectionDialogModule {}

View File

@@ -0,0 +1,2 @@
export * from "./collection-dialog.component";
export * from "./collection-dialog.module";

View File

@@ -0,0 +1,13 @@
<div class="mt-5 d-flex justify-content-center" *ngIf="loading">
<div>
<img class="mb-4 logo logo-themed" alt="Bitwarden" />
<p class="text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</p>
</div>
</div>

View File

@@ -0,0 +1,27 @@
import { Component } from "@angular/core";
import { Params } from "@angular/router";
import { BaseAcceptComponent } from "../../../common/base.accept.component";
@Component({
selector: "app-accept-family-sponsorship",
templateUrl: "accept-family-sponsorship.component.html",
})
export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
failedShortMessage = "inviteAcceptFailedShort";
failedMessage = "inviteAcceptFailed";
requiredParameters = ["email", "token"];
async authedHandler(qParams: Params) {
this.router.navigate(["/setup/families-for-enterprise"], { queryParams: qParams });
}
async unauthedHandler(qParams: Params) {
if (!qParams.register) {
this.router.navigate(["/login"], { queryParams: { email: qParams.email } });
} else {
this.router.navigate(["/register"], { queryParams: { email: qParams.email } });
}
}
}

View File

@@ -0,0 +1,53 @@
<div class="container page-content">
<div class="page-header">
<h1>{{ "sponsoredFamiliesOffer" | i18n }}</h1>
</div>
<div *ngIf="loading" class="mt-5 d-flex justify-content-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div *ngIf="!loading && badToken" class="mt-5 d-flex justify-content-center">
<span>{{ "badToken" | i18n }}</span>
</div>
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
*ngIf="!loading && !badToken"
>
<p>
<span>{{ "acceptBitwardenFamiliesHelp" | i18n }}</span>
</p>
<div class="form-group col-6">
<label for="availableSponsorshipOrg">{{ "sponsoredFamiliesSelectOffer" | i18n }}</label>
<select
id="availableSponsorshipOrg"
name="Available Sponsorship Organization"
[(ngModel)]="selectedFamilyOrganizationId"
class="form-control"
required
>
<option value="" disabled>-- {{ "select" | i18n }} --</option>
<option value="createNew">{{ "newFamiliesOrganization" | i18n }}</option>
<option *ngFor="let o of existingFamilyOrganizations$ | async" [ngValue]="o.id">
{{ o.name }}
</option>
</select>
</div>
<div *ngIf="showNewOrganization" class="col-12">
<app-organization-plans></app-organization-plans>
</div>
<div class="form-group col-6" *ngIf="!showNewOrganization">
<button class="btn btn-primary mt-2 btn-submit" [disabled]="form.loading" type="submit">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "acceptOffer" | i18n }}</span>
</button>
</div>
</form>
</div>
<ng-template #deleteOrganizationTemplate></ng-template>

View File

@@ -0,0 +1,161 @@
import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Observable, Subject } from "rxjs";
import { first, map, takeUntil } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ValidationService } from "@bitwarden/common/abstractions/validation.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationSponsorshipRedeemRequest } from "@bitwarden/common/admin-console/models/request/organization/organization-sponsorship-redeem.request";
import { PlanSponsorshipType } from "@bitwarden/common/billing/enums/plan-sponsorship-type";
import { PlanType } from "@bitwarden/common/billing/enums/plan-type";
import { ProductType } from "@bitwarden/common/enums/productType";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { OrganizationPlansComponent } from "../../../billing/settings/organization-plans.component";
import { DeleteOrganizationComponent } from "../../organizations/settings";
@Component({
selector: "families-for-enterprise-setup",
templateUrl: "families-for-enterprise-setup.component.html",
})
export class FamiliesForEnterpriseSetupComponent implements OnInit, OnDestroy {
@ViewChild(OrganizationPlansComponent, { static: false })
set organizationPlansComponent(value: OrganizationPlansComponent) {
if (!value) {
return;
}
value.plan = PlanType.FamiliesAnnually;
value.product = ProductType.Families;
value.acceptingSponsorship = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
value.onSuccess.subscribe(this.onOrganizationCreateSuccess.bind(this));
}
@ViewChild("deleteOrganizationTemplate", { read: ViewContainerRef, static: true })
deleteModalRef: ViewContainerRef;
loading = true;
badToken = false;
formPromise: Promise<any>;
token: string;
existingFamilyOrganizations: Organization[];
existingFamilyOrganizations$: Observable<Organization[]>;
showNewOrganization = false;
_organizationPlansComponent: OrganizationPlansComponent;
_selectedFamilyOrganizationId = "";
private _destroy = new Subject<void>();
constructor(
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private route: ActivatedRoute,
private apiService: ApiService,
private syncService: SyncService,
private validationService: ValidationService,
private organizationService: OrganizationService,
private modalService: ModalService
) {}
async ngOnInit() {
document.body.classList.remove("layout_frontend");
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
const error = qParams.token == null;
if (error) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("sponsoredFamiliesAcceptFailed"),
{ timeout: 10000 }
);
this.router.navigate(["/"]);
return;
}
this.token = qParams.token;
await this.syncService.fullSync(true);
this.badToken = !(await this.apiService.postPreValidateSponsorshipToken(this.token));
this.loading = false;
});
this.existingFamilyOrganizations$ = this.organizationService.organizations$.pipe(
map((orgs) => orgs.filter((o) => o.planProductType === ProductType.Families))
);
this.existingFamilyOrganizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => {
if (orgs.length === 0) {
this.selectedFamilyOrganizationId = "createNew";
}
});
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
async submit() {
this.formPromise = this.doSubmit(this._selectedFamilyOrganizationId);
await this.formPromise;
this.formPromise = null;
}
get selectedFamilyOrganizationId() {
return this._selectedFamilyOrganizationId;
}
set selectedFamilyOrganizationId(value: string) {
this._selectedFamilyOrganizationId = value;
this.showNewOrganization = value === "createNew";
}
private async doSubmit(organizationId: string) {
try {
const request = new OrganizationSponsorshipRedeemRequest();
request.planSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise;
request.sponsoredOrganizationId = organizationId;
await this.apiService.postRedeemSponsorship(this.token, request);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("sponsoredFamiliesOfferRedeemed")
);
await this.syncService.fullSync(true);
this.router.navigate(["/"]);
} catch (e) {
if (this.showNewOrganization) {
await this.modalService.openViewRef(
DeleteOrganizationComponent,
this.deleteModalRef,
(comp) => {
comp.organizationId = organizationId;
comp.deleteOrganizationRequestType = "InvalidFamiliesForEnterprise";
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
comp.onSuccess.subscribe(() => {
this.router.navigate(["/"]);
});
}
);
}
this.validationService.showError(this.i18nService.t("sponsorshipTokenHasExpired"));
}
}
private async onOrganizationCreateSuccess(value: any) {
// Use newly created organization id
await this.doSubmit(value.organizationId);
}
}

View File

@@ -0,0 +1,52 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
// eslint-disable-next-line no-restricted-imports
import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent } from "../../../reports/pages/exposed-passwords-report.component";
@Component({
selector: "app-org-exposed-passwords-report",
templateUrl: "../../../reports/pages/exposed-passwords-report.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportComponent {
manageableCiphers: Cipher[];
constructor(
cipherService: CipherService,
auditService: AuditService,
modalService: ModalService,
messagingService: MessagingService,
private organizationService: OrganizationService,
private route: ActivatedRoute,
passwordRepromptService: PasswordRepromptService
) {
super(cipherService, auditService, modalService, messagingService, passwordRepromptService);
}
ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);
this.manageableCiphers = await this.cipherService.getAll();
await this.checkAccess();
});
}
getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllFromApiForOrganization(this.organization.id);
}
canManageCipher(c: CipherView): boolean {
return this.manageableCiphers.some((x) => x.id === c.id);
}
}

View File

@@ -0,0 +1,86 @@
import { Component } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { ExportService } from "@bitwarden/common/abstractions/export.service";
import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { EventType } from "@bitwarden/common/enums/eventType";
import { ExportComponent } from "../../../../tools/import-export/export.component";
@Component({
selector: "app-org-export",
templateUrl: "../../../../tools/import-export/export.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class OrganizationExportComponent extends ExportComponent {
constructor(
cryptoService: CryptoService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
exportService: ExportService,
eventCollectionService: EventCollectionService,
private route: ActivatedRoute,
policyService: PolicyService,
logService: LogService,
userVerificationService: UserVerificationService,
formBuilder: UntypedFormBuilder,
fileDownloadService: FileDownloadService,
modalService: ModalService
) {
super(
cryptoService,
i18nService,
platformUtilsService,
exportService,
eventCollectionService,
policyService,
logService,
userVerificationService,
formBuilder,
fileDownloadService,
modalService
);
}
async ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
});
await super.ngOnInit();
}
async checkExportDisabled() {
return;
}
getExportData() {
if (this.isFileEncryptedExport) {
return this.exportService.getPasswordProtectedExport(this.filePassword, this.organizationId);
} else {
return this.exportService.getOrganizationExport(this.organizationId, this.format);
}
}
getFileName() {
return super.getFileName("org");
}
async collectEvent(): Promise<void> {
await this.eventCollectionService.collect(
EventType.Organization_ClientExportedVault,
null,
null,
this.organizationId
);
}
}

Some files were not shown because too many files have changed in this diff Show More