[PM-1337] Hide Organization options for users without master password (#6650)

* [PM-1337] Remove unused ModalService

* [PM-1337] Use memberOrganization$ instead of deprecated isMember filter

* [PM-1337] Move bitMenu into organization-options.component.html and update show/hide logic for various options

* [PM-1337] Use observables for injected data in dynamic vault filter option

Dynamic components do not currently support input data binding (available in Angular 16) so an observable must be passed into and subscribed by the dynamic component to receive updates.

* [PM-1337] Cleanup organization-options.component.ts

* [PM-1337] Use bitMenu directives instead of explicit TW classes

* [PM-1337] Refactor app-link-sso into a directive to remove redundant template

* [PM-1337] Fix failing tests
This commit is contained in:
Shane Melton 2023-11-09 10:12:00 -08:00 committed by GitHub
parent 6c3cb841a2
commit 4446c09fd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 133 additions and 113 deletions

View File

@ -1,9 +0,0 @@
<a
class="tw-block tw-cursor-pointer tw-border-none tw-bg-background tw-px-4 tw-py-2 tw-text-left !tw-text-main tw-no-underline hover:tw-bg-secondary-100 hover:tw-no-underline focus:tw-z-50 focus:tw-bg-secondary-100 focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2 active:!tw-ring-0 active:!tw-ring-offset-0"
href="#"
appStopClick
(click)="submit(returnUri, true)"
>
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
{{ "linkSso" | i18n }}
</a>

View File

@ -1,4 +1,4 @@
import { AfterContentInit, Component, Input } from "@angular/core";
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
@ -14,14 +14,19 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
@Component({
selector: "app-link-sso",
templateUrl: "link-sso.component.html",
@Directive({
selector: "[app-link-sso]",
})
export class LinkSsoComponent extends SsoComponent implements AfterContentInit {
export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
@Input() organization: Organization;
returnUri = "/settings/organizations";
@HostListener("click", ["$event"])
async onClick($event: MouseEvent) {
$event.preventDefault();
await this.submit(this.returnUri, true);
}
constructor(
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,

View File

@ -1,54 +1,60 @@
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted tw-m-2"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<div
*ngIf="loaded"
class="tw-flex tw-min-w-[200px] tw-max-w-[300px] tw-flex-col"
[appApiAction]="actionPromise"
>
<button
type="button"
*ngIf="allowEnrollmentChanges(organization) && !organization.resetPasswordEnrolled"
class="tw-block tw-cursor-pointer tw-border-none tw-bg-background tw-px-4 tw-py-2 tw-text-left !tw-text-main hover:tw-bg-secondary-100 focus:tw-z-50 focus:tw-bg-secondary-100 focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2 active:!tw-ring-0 active:!tw-ring-offset-0"
(click)="toggleResetPasswordEnrollment(organization)"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
{{ "enrollAccountRecovery" | i18n }}
<ng-container *ngIf="!hideMenu">
<button type="button" [bitMenuTriggerFor]="optionsMenu" class="filter-options-icon">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<button
type="button"
*ngIf="allowEnrollmentChanges(organization) && organization.resetPasswordEnrolled"
class="tw-block tw-cursor-pointer tw-border-none tw-bg-background tw-px-4 tw-py-2 tw-text-left !tw-text-main hover:tw-bg-secondary-100 focus:tw-z-50 focus:tw-bg-secondary-100 focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2 active:!tw-ring-0 active:!tw-ring-offset-0"
(click)="toggleResetPasswordEnrollment(organization)"
>
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "withdrawAccountRecovery" | i18n }}
</button>
<ng-container *ngIf="organization.useSso && organization.identifier">
<button
type="button"
*ngIf="organization.ssoBound; else linkSso"
class="tw-block tw-cursor-pointer tw-border-none tw-bg-background tw-px-4 tw-py-2 tw-text-left !tw-text-main hover:tw-bg-secondary-100 focus:tw-z-50 focus:tw-bg-secondary-100 focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2 active:!tw-ring-0 active:!tw-ring-offset-0"
(click)="unlinkSso(organization)"
<bit-menu class="filter-organization-options" #optionsMenu>
<ng-container *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin tw-m-2 tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<div
*ngIf="loaded"
class="tw-flex tw-min-w-[200px] tw-max-w-[300px] tw-flex-col"
[appApiAction]="actionPromise"
>
<i class="bwi bwi-fw bwi-chain-broken" aria-hidden="true"></i>
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<app-link-sso [organization]="organization"> </app-link-sso>
</ng-template>
</ng-container>
<button
type="button"
class="text-danger tw-block tw-cursor-pointer tw-border-none tw-bg-background tw-px-4 tw-py-2 tw-text-left hover:tw-bg-secondary-100 focus:tw-z-50 focus:tw-bg-secondary-100 focus:tw-outline-none focus:tw-ring focus:tw-ring-primary-700 focus:tw-ring-offset-2 active:!tw-ring-0 active:!tw-ring-offset-0"
(click)="leave(organization)"
>
<i class="bwi bwi-fw bwi-sign-out" aria-hidden="true"></i>
{{ "leave" | i18n }}
</button>
</div>
<button
type="button"
*ngIf="allowEnrollmentChanges(organization) && !organization.resetPasswordEnrolled"
bitMenuItem
(click)="toggleResetPasswordEnrollment(organization)"
>
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i>
{{ "enrollAccountRecovery" | i18n }}
</button>
<button
type="button"
*ngIf="allowEnrollmentChanges(organization) && organization.resetPasswordEnrolled"
bitMenuItem
(click)="toggleResetPasswordEnrollment(organization)"
>
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "withdrawAccountRecovery" | i18n }}
</button>
<ng-container *ngIf="showSsoOptions(organization)">
<button
type="button"
*ngIf="organization.ssoBound; else linkSso"
bitMenuItem
(click)="unlinkSso(organization)"
>
<i class="bwi bwi-fw bwi-chain-broken" aria-hidden="true"></i>
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<a href="#" bitMenuItem app-link-sso [organization]="organization">
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
{{ "linkSso" | i18n }}
</a>
</ng-template>
</ng-container>
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">
<i class="bwi bwi-fw bwi-sign-out tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "leave" | i18n }}</span>
</button>
</div>
</bit-menu>
</ng-container>

View File

@ -1,7 +1,6 @@
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import { map, Subject, takeUntil } from "rxjs";
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
@ -13,6 +12,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService } from "@bitwarden/components";
@ -25,34 +25,58 @@ import { OrganizationFilter } from "../shared/models/vault-filter.type";
templateUrl: "organization-options.component.html",
})
export class OrganizationOptionsComponent implements OnInit, OnDestroy {
actionPromise: Promise<void | boolean>;
policies: Policy[];
loaded = false;
protected actionPromise: Promise<void | boolean>;
protected resetPasswordPolicy?: Policy | undefined;
protected loaded = false;
protected hideMenu = false;
protected showLeaveOrgOption = false;
protected organization: OrganizationFilter;
private destroy$ = new Subject<void>();
constructor(
@Inject(OptionsInput) protected organization: OrganizationFilter,
@Inject(OptionsInput) protected organization$: Observable<OrganizationFilter>,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private apiService: ApiService,
private syncService: SyncService,
private policyService: PolicyService,
private modalService: ModalService,
private logService: LogService,
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationUserService: OrganizationUserService,
private dialogService: DialogService
private dialogService: DialogService,
private stateService: StateService
) {}
async ngOnInit() {
this.policyService.policies$
.pipe(
map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)),
takeUntil(this.destroy$)
)
.subscribe((policies) => {
this.policies = policies;
const resetPasswordPolicies$ = this.policyService.policies$.pipe(
map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword))
);
combineLatest([
this.organization$,
resetPasswordPolicies$,
this.stateService.getAccountDecryptionOptions(),
])
.pipe(takeUntil(this.destroy$))
.subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => {
this.organization = organization;
this.resetPasswordPolicy = resetPasswordPolicies.find(
(p) => p.organizationId === organization.id
);
// A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password.
this.showLeaveOrgOption =
(decryptionOptions.trustedDeviceOption == undefined &&
decryptionOptions.keyConnectorOption == undefined) ||
decryptionOptions.hasMasterPassword;
// Hide the 3 dot menu if the user has no available actions
this.hideMenu =
!this.showLeaveOrgOption &&
!this.showSsoOptions(this.organization) &&
!this.allowEnrollmentChanges(this.organization);
this.loaded = true;
});
}
@ -64,21 +88,16 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
allowEnrollmentChanges(org: OrganizationFilter): boolean {
if (org.usePolicies && org.useResetPassword && org.hasPublicAndPrivateKeys) {
const policy = this.policies.find((p) => p.organizationId === org.id);
if (policy != null && policy.enabled) {
return org.resetPasswordEnrolled && policy.data.autoEnrollEnabled ? false : true;
if (this.resetPasswordPolicy != undefined && this.resetPasswordPolicy.enabled) {
return !(org.resetPasswordEnrolled && this.resetPasswordPolicy.data.autoEnrollEnabled);
}
}
return false;
}
showEnrolledStatus(org: Organization): boolean {
return (
org.useResetPassword &&
org.resetPasswordEnrolled &&
this.policies.some((p) => p.organizationId === org.id && p.enabled)
);
showSsoOptions(org: OrganizationFilter) {
return org.useSso && org.identifier;
}
async unlinkSso(org: Organization) {
@ -143,7 +162,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
null,
this.i18nService.t("withdrawPasswordResetSuccess")
);
this.syncService.fullSync(true);
await this.syncService.fullSync(true);
} catch (e) {
this.logService.error(e);
}

View File

@ -39,7 +39,7 @@ describe("vault filter service", () => {
organizations = new ReplaySubject<Organization[]>(1);
folderViews = new ReplaySubject<FolderView[]>(1);
organizationService.organizations$ = organizations;
organizationService.memberOrganizations$ = organizations;
folderService.folderViews$ = folderViews;
vaultFilterService = new VaultFilterService(

View File

@ -11,10 +11,7 @@ import {
switchMap,
} from "rxjs";
import {
isMember,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@ -48,7 +45,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
);
organizationTree$: Observable<TreeNode<OrganizationFilter>> =
this.organizationService.organizations$.pipe(
this.organizationService.memberOrganizations$.pipe(
switchMap((orgs) => this.buildOrganizationTree(orgs))
);
@ -139,7 +136,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
}
if (orgs) {
const orgNodes: TreeNode<OrganizationFilter>[] = [];
orgs.filter(isMember).forEach((org) => {
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
orgCopy.icon = "bwi-business";
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);

View File

@ -98,16 +98,11 @@
class="org-options bwi bwi-fw bwi-exclamation-triangle text-danger"
[attr.aria-label]="'organizationIsDisabled' | i18n"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i
><ng-container *ngIf="optionsInfo && !f.node.hideOptions"
><button type="button" [bitMenuTriggerFor]="optionsMenu" class="filter-options-icon">
<i class="bwi bwi-ellipsis-v" aria-hidden="true"></i>
</button>
<bit-menu class="filter-organization-options" #optionsMenu>
<ng-container
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</bit-menu>
></i>
<ng-container *ngIf="optionsInfo && !f.node.hideOptions">
<ng-container
*ngComponentOutlet="optionsInfo.component; injector: createInjector(f.node)"
></ng-container>
</ng-container>
</span>
</span>

View File

@ -1,5 +1,6 @@
import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { Observable, Subject, takeUntil } from "rxjs";
import { map } from "rxjs/operators";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/models/domain/tree-node";
@ -120,9 +121,15 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
// here we are creating a new injector for each filter that has options
createInjector(data: VaultFilterType) {
let inject = this.injectors.get(data.id);
if (!inject) {
// Pass an observable to the component in order to update the component when the data changes
// as data binding does not work with dynamic components in Angular 15 (inputs are supported starting Angular 16)
const data$ = this.section.data$.pipe(
map((sectionNode) => sectionNode?.children?.find((node) => node.node.id === data.id)?.node)
);
inject = Injector.create({
providers: [{ provide: OptionsInput, useValue: data }],
providers: [{ provide: OptionsInput, useValue: data$ }],
parent: this.injector,
});
this.injectors.set(data.id, inject);
@ -130,4 +137,4 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
return inject;
}
}
export const OptionsInput = new InjectionToken<VaultFilterType>("OptionsInput");
export const OptionsInput = new InjectionToken<Observable<VaultFilterType>>("OptionsInput");

View File

@ -2,7 +2,7 @@ import { NgModule } from "@angular/core";
import { VaultFilterSharedModule } from "../../individual-vault/vault-filter/shared/vault-filter-shared.module";
import { LinkSsoComponent } from "./components/link-sso.component";
import { LinkSsoDirective } from "./components/link-sso.directive";
import { OrganizationOptionsComponent } from "./components/organization-options.component";
import { VaultFilterComponent } from "./components/vault-filter.component";
import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service";
@ -10,7 +10,7 @@ import { VaultFilterService } from "./services/vault-filter.service";
@NgModule({
imports: [VaultFilterSharedModule],
declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoComponent],
declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoDirective],
exports: [VaultFilterComponent],
providers: [
{