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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app/admin-console/core/policy-list.service.ts
Normal file
13
apps/web/src/app/admin-console/core/policy-list.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(["/"]);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">×</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> </th>
|
||||
<th> </th>
|
||||
<th>{{ "name" | i18n }}</th>
|
||||
<th *ngIf="entity === 'collection'"> </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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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">×</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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./member-dialog.component";
|
||||
export * from "./member-dialog.module";
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" } };
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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";
|
||||
@@ -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">!@#$%^&*</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">!@#$%^&*</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>
|
||||
@@ -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" },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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">×</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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./organization-settings.module";
|
||||
export { DeleteOrganizationComponent } from "./delete-organization.component";
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./access-selector.component";
|
||||
export * from "./access-selector.module";
|
||||
export * from "./access-selector.models";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./collection-dialog.component";
|
||||
export * from "./collection-dialog.module";
|
||||
@@ -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>
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user