1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-09 20:13:42 +00:00

Merge branch 'master' into AC-1423-update-organization-subscription-cloud-page

This commit is contained in:
Shane Melton
2023-06-20 11:44:07 -07:00
262 changed files with 6299 additions and 1142 deletions

View File

@@ -0,0 +1,17 @@
import { svgIcon } from "@bitwarden/components";
export const Devices = svgIcon`
<svg width="201" height="201" viewBox="0 0 201 201" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity=".49">
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M34.3628 82.0889H10.3628C7.04908 82.0889 4.36279 84.7752 4.36279 88.0889V148.089C4.36279 151.403 7.04909 154.089 10.3628 154.089H34.3628C37.6765 154.089 40.3628 151.403 40.3628 148.089V88.0889C40.3628 84.7752 37.6765 82.0889 34.3628 82.0889ZM10.3628 78.0889C4.83995 78.0889 0.362793 82.566 0.362793 88.0889V148.089C0.362793 153.612 4.83995 158.089 10.3628 158.089H34.3628C39.8856 158.089 44.3628 153.612 44.3628 148.089V88.0889C44.3628 82.566 39.8856 78.0889 34.3628 78.0889H10.3628Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M20.7329 86.8979C20.7329 86.3457 21.1806 85.8979 21.7329 85.8979H22.975C23.5273 85.8979 23.975 86.3457 23.975 86.8979C23.975 87.4502 23.5273 87.8979 22.975 87.8979H21.7329C21.1806 87.8979 20.7329 87.4502 20.7329 86.8979Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M68.3628 159.089C68.3628 158.537 68.8105 158.089 69.3628 158.089H127.363C127.915 158.089 128.363 158.537 128.363 159.089C128.363 159.641 127.915 160.089 127.363 160.089H69.3628C68.8105 160.089 68.3628 159.641 68.3628 159.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M88.103 159.089V141.325H90.103V159.089H88.103Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M108.073 159.089V141.325H110.073V159.089H108.073Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M27.3628 64.0889C27.3628 56.3569 33.6308 50.0889 41.3628 50.0889H157.363C165.095 50.0889 171.363 56.3569 171.363 64.0889V70.0889H167.363V64.0889C167.363 58.566 162.886 54.0889 157.363 54.0889H41.3628C35.8399 54.0889 31.3628 58.566 31.3628 64.0889V80.0889H27.3628V64.0889ZM42.3628 138.089H127.363V142.089H42.3628V138.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M35.3628 65.0889C35.3628 61.2229 38.4968 58.0889 42.3628 58.0889H156.363C160.229 58.0889 163.363 61.2229 163.363 65.0889V70.0889H161.363V65.0889C161.363 62.3274 159.124 60.0889 156.363 60.0889H42.3628C39.6014 60.0889 37.3628 62.3274 37.3628 65.0889V80.0889H35.3628V65.0889ZM42.3628 132.089H127.363V134.089H42.3628V132.089Z" />
<path class="tw-fill-secondary-500" fill-rule="evenodd" clip-rule="evenodd" d="M125.363 78.0889C125.363 72.566 129.84 68.0889 135.363 68.0889H188.363C193.886 68.0889 198.363 72.566 198.363 78.0889V158.089C198.363 163.612 193.886 168.089 188.363 168.089H135.363C129.84 168.089 125.363 163.612 125.363 158.089V78.0889ZM135.363 72.0889C132.049 72.0889 129.363 74.7752 129.363 78.0889V158.089C129.363 161.403 132.049 164.089 135.363 164.089H188.363C191.676 164.089 194.363 161.403 194.363 158.089V78.0889C194.363 74.7752 191.677 72.0889 188.363 72.0889H135.363Z" />
<path class="tw-fill-secondary-500" d="M164.363 159.089C164.363 160.193 163.467 161.089 162.363 161.089C161.258 161.089 160.363 160.193 160.363 159.089C160.363 157.984 161.258 157.089 162.363 157.089C163.467 157.089 164.363 157.984 164.363 159.089Z" />
</g>
</svg>
`;

View File

@@ -0,0 +1 @@
export * from "./devices";

View File

@@ -83,6 +83,7 @@ export class UserAdminService {
}));
view.groups = u.groups;
view.accessSecretsManager = u.accessSecretsManager;
view.hasMasterPassword = u.hasMasterPassword;
return view;
});

View File

@@ -16,6 +16,7 @@ export class OrganizationUserAdminView {
accessAll: boolean;
permissions: PermissionsApi;
resetPasswordEnrolled: boolean;
hasMasterPassword: boolean;
collections: CollectionAccessSelectionView[] = [];
groups: string[] = [];

View File

@@ -20,6 +20,7 @@ export class OrganizationUserView {
avatarColor: string;
twoFactorEnabled: boolean;
usesKeyConnector: boolean;
hasMasterPassword: boolean;
collections: CollectionAccessSelectionView[] = [];
groups: string[] = [];

View File

@@ -1,118 +1,89 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="eventLogsTitle">
<div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title" id="eventLogsTitle">
{{ "eventLogs" | i18n }}
<small class="text-muted" *ngIf="name">{{ name }}</small>
</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" *ngIf="!loaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</div>
<div class="modal-body" *ngIf="loaded">
<div class="d-flex">
<div class="form-inline">
<label class="sr-only" for="start">{{ "startDate" | i18n }}</label>
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle>
{{ "eventLogs" | i18n }}
<small class="tw-text-muted" *ngIf="name">{{ name }}</small>
</span>
<div bitDialogContent>
<form [formGroup]="filterFormGroup" [bitSubmit]="refreshEvents">
<div class="tw-flex tw-items-center tw-space-x-2">
<div>
<label class="tw-sr-only" for="start">{{ "startDate" | i18n }}</label>
<span>
<input
bitInput
type="datetime-local"
class="form-control form-control-sm"
id="start"
placeholder="{{ 'startDate' | i18n }}"
[(ngModel)]="start"
placeholder="YYYY-MM-DDTHH:MM"
formControlName="start"
/>
<span class="mx-2">-</span>
<label class="sr-only" for="end">{{ "endDate" | i18n }}</label>
</span>
</div>
<span class="tw-mx-2">-</span>
<div>
<label class="tw-sr-only" for="end">{{ "endDate" | i18n }}</label>
<span>
<input
bitInput
type="datetime-local"
class="form-control form-control-sm"
id="end"
placeholder="{{ 'endDate' | i18n }}"
[(ngModel)]="end"
placeholder="YYYY-MM-DDTHH:MM"
formControlName="end"
/>
</div>
<button
#refreshBtn
[appApiAction]="refreshPromise"
type="button"
class="btn btn-sm btn-outline-primary ml-3"
(click)="loadEvents(true)"
[disabled]="loaded && $any(refreshBtn).loading"
>
</span>
</div>
<button type="submit" bitButton buttonType="primary" bitFormButton>
<i class="bwi bwi-refresh bwi-fw" aria-hidden="true"></i>
{{ "refresh" | i18n }}
</button>
</div>
</form>
<hr />
<div *ngIf="!dataSource.data || !dataSource.data.length">
{{ "noEventsInList" | i18n }}
</div>
<bit-table [dataSource]="dataSource" *ngIf="dataSource?.data?.length">
<ng-container header>
<tr>
<th bitCell>{{ "timestamp" | i18n }}</th>
<th bitCell>
<span class="tw-sr-only">{{ "client" | i18n }}</span>
</th>
<th bitCell *ngIf="showUser">{{ "member" | i18n }}</th>
<th bitCell>{{ "event" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>{{ r.date | date : "medium" }}</td>
<td bitCell>
<i
class="bwi bwi-refresh bwi-fw"
[ngClass]="{ 'bwi-spin': loaded && $any(refreshBtn).loading }"
class="tw-text-muted bwi bwi-lg {{ r.appIcon }}"
title="{{ r.appName }}, {{ r.ip }}"
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>
<span class="tw-sr-only">{{ r.appName }}, {{ r.ip }}</span>
</td>
<td bitCell *ngIf="showUser">
<span appA11yTitle="{{ r.userEmail }}">{{ r.userName }}</span>
</td>
<td bitCell [innerHTML]="r.message"></td>
</tr>
</ng-template>
</bit-table>
<button
bitButton
buttonType="secondary"
block
[bitAction]="loadMoreEvents"
type="button"
*ngIf="continuationToken"
>
{{ "loadMore" | i18n }}
</button>
</div>
</div>
<ng-container bitDialogFooter>
<button bitButton buttonType="secondary" type="button" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -1,78 +1,114 @@
import { Component, Input, OnInit } from "@angular/core";
import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { EventView } from "@bitwarden/common/models/view/event.view";
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { TableDataSource } from "@bitwarden/components";
import { EventService } from "../../../core";
import { SharedModule } from "../../../shared";
export interface EntityEventsDialogParams {
entity: "user" | "cipher";
entityId: string;
organizationId?: string;
providerId?: string;
showUser?: boolean;
name?: string;
}
@Component({
selector: "app-entity-events",
imports: [SharedModule],
templateUrl: "entity-events.component.html",
standalone: true,
})
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>;
protected dataSource = new TableDataSource<EventView>();
protected filterFormGroup = this.formBuilder.group({
start: [""],
end: [""],
});
private orgUsersUserIdMap = new Map<string, any>();
private orgUsersIdMap = new Map<string, any>();
get name() {
return this.params.name;
}
get showUser() {
return this.params.showUser ?? false;
}
constructor(
@Inject(DIALOG_DATA) private params: EntityEventsDialogParams,
private apiService: ApiService,
private i18nService: I18nService,
private eventService: EventService,
private platformUtilsService: PlatformUtilsService,
private userNamePipe: UserNamePipe,
private logService: LogService,
private organizationUserService: OrganizationUserService
private organizationUserService: OrganizationUserService,
private formBuilder: FormBuilder,
private validationService: ValidationService
) {}
async ngOnInit() {
const defaultDates = this.eventService.getDefaultDateFilters();
this.start = defaultDates[0];
this.end = defaultDates[1];
this.filterFormGroup.setValue({
start: defaultDates[0],
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 });
});
try {
if (this.showUser) {
const response = await this.organizationUserService.getAllUsers(this.params.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);
} catch (e) {
this.logService.error(e);
this.validationService.showError(e);
}
await this.loadEvents(true);
this.loaded = true;
this.loading = false;
}
async loadEvents(clearExisting: boolean) {
if (this.refreshPromise != null || this.morePromise != null) {
return;
}
loadMoreEvents = async () => {
await this.loadEvents(false);
};
refreshEvents = async () => {
await this.loadEvents(true);
};
private async loadEvents(clearExisting: boolean) {
let dates: string[] = null;
try {
dates = this.eventService.formatDateFilters(this.start, this.end);
dates = this.eventService.formatDateFilters(
this.filterFormGroup.value.start,
this.filterFormGroup.value.end
);
} catch (e) {
this.platformUtilsService.showToast(
"error",
@@ -82,46 +118,34 @@ export class EntityEventsComponent implements OnInit {
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);
if (this.params.entity === "user" && this.params.providerId) {
response = await this.apiService.getEventsProviderUser(
this.params.providerId,
this.params.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
} else if (this.params.entity === "user") {
response = await this.apiService.getEventsOrganizationUser(
this.params.organizationId,
this.params.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
} else {
response = await this.apiService.getEventsCipher(
this.params.entityId,
dates[0],
dates[1],
clearExisting ? null : this.continuationToken
);
}
this.continuationToken = response.continuationToken;
const events = await Promise.all(
const events: EventView[] = await Promise.all(
response.data.map(async (r) => {
const userId = r.actingUserId == null ? r.userId : r.actingUserId;
const eventInfo = await this.eventService.getEventInfo(r);
@@ -129,8 +153,10 @@ export class EntityEventsComponent implements OnInit {
this.showUser && userId != null && this.orgUsersUserIdMap.has(userId)
? this.orgUsersUserIdMap.get(userId)
: null;
return {
return new EventView({
message: eventInfo.message,
humanReadableMessage: eventInfo.humanReadableMessage,
appIcon: eventInfo.appIcon,
appName: eventInfo.appName,
userId: userId,
@@ -139,18 +165,29 @@ export class EntityEventsComponent implements OnInit {
date: r.date,
ip: r.ipAddress,
type: r.type,
};
installationId: r.installationId,
systemUser: r.systemUser,
serviceAccountId: r.serviceAccountId,
});
})
);
if (!clearExisting && this.events != null && this.events.length > 0) {
this.events = this.events.concat(events);
if (!clearExisting && this.dataSource.data != null && this.dataSource.data.length > 0) {
this.dataSource.data = this.dataSource.data.concat(events);
} else {
this.events = events;
this.dataSource.data = events;
}
this.loading = false;
this.morePromise = null;
this.refreshPromise = null;
}
}
/**
* Strongly typed helper to open a EntityEventsComponent as a dialog
* @param dialogService Instance of the dialog service that will be used to open the dialog
* @param config Configuration for the dialog
*/
export const openEntityEventsDialog = (
dialogService: DialogServiceAbstraction,
config: DialogConfig<EntityEventsDialogParams>
) => {
return dialogService.open<void, EntityEventsDialogParams>(EntityEventsComponent, config);
};

View File

@@ -23,12 +23,16 @@
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error">
{{ removeUsersWarning }}
<p>{{ removeUsersWarning }}</p>
<p *ngIf="this.showNoMasterPasswordWarning">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
@@ -39,6 +43,15 @@
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="this.showNoMasterPasswordWarning">
<span class="text-muted d-block tw-lowercase">
<ng-container *ngIf="user.hasMasterPassword === true"> - </ng-container>
<ng-container *ngIf="user.hasMasterPassword === false">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "noMasterPassword" | i18n }}
</ng-container>
</span>
</td>
</tr>
</table>
</ng-container>

View File

@@ -12,13 +12,23 @@ import { BulkUserDetails } from "./bulk-status.component";
})
export class BulkRemoveComponent {
@Input() organizationId: string;
@Input() users: BulkUserDetails[];
@Input() set users(value: BulkUserDetails[]) {
this._users = value;
this.showNoMasterPasswordWarning = this._users.some((u) => u.hasMasterPassword === false);
}
get users(): BulkUserDetails[] {
return this._users;
}
private _users: BulkUserDetails[];
statuses: Map<string, string> = new Map();
loading = false;
done = false;
error: string;
showNoMasterPasswordWarning = false;
constructor(
protected apiService: ApiService,

View File

@@ -23,12 +23,16 @@
</app-callout>
<ng-container *ngIf="!done">
<app-callout type="warning" *ngIf="users.length > 0 && !error && isRevoking">
{{ "revokeUsersWarning" | i18n }}
<p>{{ "revokeUsersWarning" | i18n }}</p>
<p *ngIf="this.showNoMasterPasswordWarning">
{{ "removeMembersWithoutMasterPasswordWarning" | i18n }}
</p>
</app-callout>
<table class="table table-hover table-list">
<thead>
<tr>
<th colspan="2">{{ "user" | i18n }}</th>
<th *ngIf="this.showNoMasterPasswordWarning">{{ "details" | i18n }}</th>
</tr>
</thead>
<tr *ngFor="let user of users">
@@ -39,6 +43,15 @@
{{ user.email }}
<small class="text-muted d-block" *ngIf="user.name">{{ user.name }}</small>
</td>
<td *ngIf="this.showNoMasterPasswordWarning">
<span class="text-muted d-block tw-lowercase">
<ng-container *ngIf="user.hasMasterPassword === true"> - </ng-container>
<ng-container *ngIf="user.hasMasterPassword === false">
<i class="bwi bwi-exclamation-triangle" aria-hidden="true"></i>
{{ "noMasterPassword" | i18n }}
</ng-container>
</span>
</td>
</tr>
</table>
</ng-container>

View File

@@ -20,6 +20,7 @@ export class BulkRestoreRevokeComponent {
loading = false;
done = false;
error: string;
showNoMasterPasswordWarning = false;
constructor(
protected i18nService: I18nService,
@@ -29,6 +30,7 @@ export class BulkRestoreRevokeComponent {
this.isRevoking = config.data.isRevoking;
this.organizationId = config.data.organizationId;
this.users = config.data.users;
this.showNoMasterPasswordWarning = this.users.some((u) => u.hasMasterPassword === false);
}
get bulkTitle() {

View File

@@ -10,6 +10,7 @@ export interface BulkUserDetails {
name: string;
email: string;
status: OrganizationUserStatusType | ProviderUserStatusType;
hasMasterPassword?: boolean;
}
type BulkStatusEntry = {

View File

@@ -35,6 +35,7 @@ import {
} from "../../../shared/components/access-selector";
import { commaSeparatedEmails } from "./validators/comma-separated-emails.validator";
import { freeOrgSeatLimitReachedValidator } from "./validators/free-org-inv-limit-reached.validator";
export enum MemberDialogTab {
Role = 0,
@@ -46,6 +47,7 @@ export interface MemberDialogParams {
name: string;
organizationId: string;
organizationUserId: string;
allOrganizationUserEmails: string[];
usesKeyConnector: boolean;
initialTab?: MemberDialogTab;
}
@@ -72,13 +74,14 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
canUseCustomPermissions: boolean;
PermissionMode = PermissionMode;
canUseSecretsManager: boolean;
showNoMasterPasswordWarning = false;
protected organization: Organization;
protected collectionAccessItems: AccessItemView[] = [];
protected groupAccessItems: AccessItemView[] = [];
protected tabIndex: MemberDialogTab;
protected formGroup = this.formBuilder.group({
emails: ["", [Validators.required, commaSeparatedEmails]],
emails: ["", { updateOn: "blur" }],
type: OrganizationUserType.User,
externalId: this.formBuilder.control({ value: "", disabled: true }),
accessAllCollections: false,
@@ -166,6 +169,20 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
this.canUseCustomPermissions = organization.useCustomPermissions;
this.canUseSecretsManager = organization.useSecretsManager && flagEnabled("secretsManager");
const emailsControlValidators = [
Validators.required,
commaSeparatedEmails,
freeOrgSeatLimitReachedValidator(
this.organization,
this.params.allOrganizationUserEmails,
this.i18nService.t("subscriptionFreePlan", organization.seats)
),
];
const emailsControl = this.formGroup.get("emails");
emailsControl.setValidators(emailsControlValidators);
emailsControl.updateValueAndValidity();
this.collectionAccessItems = [].concat(
collections.map((c) => mapCollectionToAccessItemView(c))
);
@@ -179,6 +196,9 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
throw new Error("Could not find user to edit.");
}
this.isRevoked = userDetails.status === OrganizationUserStatusType.Revoked;
this.showNoMasterPasswordWarning =
userDetails.status > OrganizationUserStatusType.Invited &&
userDetails.hasMasterPassword === false;
const assignedCollectionsPermissions = {
editAssignedCollections: userDetails.permissions.editAssignedCollections,
deleteAssignedCollections: userDetails.permissions.deleteAssignedCollections,
@@ -366,7 +386,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
const confirmed = await this.dialogService.openSimpleDialog({
let confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removeUserIdAccess", placeholders: [this.params.name] },
content: { key: message },
type: SimpleDialogType.WARNING,
@@ -376,6 +396,14 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
return false;
}
if (this.showNoMasterPasswordWarning) {
confirmed = await this.noMasterPasswordConfirmationDialog();
if (!confirmed) {
return false;
}
}
await this.organizationUserService.deleteOrganizationUser(
this.params.organizationId,
this.params.organizationUserId
@@ -394,7 +422,7 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
let confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeUserId", placeholders: [this.params.name] },
content: { key: "revokeUserConfirmation" },
acceptButtonText: { key: "revokeAccess" },
@@ -405,6 +433,14 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
return false;
}
if (this.showNoMasterPasswordWarning) {
confirmed = await this.noMasterPasswordConfirmationDialog();
if (!confirmed) {
return false;
}
}
await this.organizationUserService.revokeOrganizationUser(
this.params.organizationId,
this.params.organizationUserId
@@ -450,6 +486,19 @@ export class MemberDialogComponent implements OnInit, OnDestroy {
private close(result: MemberDialogResult) {
this.dialogRef.close(result);
}
private noMasterPasswordConfirmationDialog() {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.params.name],
},
type: SimpleDialogType.WARNING,
});
}
}
function mapCollectionToAccessItemView(

View File

@@ -0,0 +1,106 @@
import { AbstractControl, FormControl, ValidationErrors } from "@angular/forms";
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
import { freeOrgSeatLimitReachedValidator } from "./free-org-inv-limit-reached.validator";
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props
);
describe("freeOrgSeatLimitReachedValidator", () => {
let organization: Organization;
let allOrganizationUserEmails: string[];
let validatorFn: (control: AbstractControl) => ValidationErrors | null;
beforeEach(() => {
allOrganizationUserEmails = ["user1@example.com"];
});
it("should return null when control value is empty", () => {
validatorFn = freeOrgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
);
const control = new FormControl("");
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should return null when control value is null", () => {
validatorFn = freeOrgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
);
const control = new FormControl(null);
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should return null when max seats are not exceeded on free plan", () => {
organization = orgFactory({
planProductType: ProductType.Free,
seats: 2,
});
validatorFn = freeOrgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
);
const control = new FormControl("user2@example.com");
const result = validatorFn(control);
expect(result).toBeNull();
});
it("should return validation error when max seats are exceeded on free plan", () => {
organization = orgFactory({
planProductType: ProductType.Free,
seats: 2,
});
const errorMessage = "You cannot invite more than 2 members without upgrading your plan.";
validatorFn = freeOrgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
);
const control = new FormControl("user2@example.com,user3@example.com");
const result = validatorFn(control);
expect(result).toStrictEqual({ freePlanLimitReached: { message: errorMessage } });
});
it("should return null when not on free plan", () => {
const control = new FormControl("user2@example.com,user3@example.com");
organization = orgFactory({
planProductType: ProductType.Enterprise,
seats: 100,
});
validatorFn = freeOrgSeatLimitReachedValidator(
organization,
allOrganizationUserEmails,
"You cannot invite more than 2 members without upgrading your plan."
);
const result = validatorFn(control);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,36 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from "@angular/forms";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProductType } from "@bitwarden/common/enums";
/**
* Checks if the limit of free organization seats has been reached when adding new users
* @param organization An object representing the organization
* @param allOrganizationUserEmails An array of strings with existing user email addresses
* @param errorMessage A localized string to display if validation fails
* @returns A function that validates an `AbstractControl` and returns `ValidationErrors` or `null`
*/
export function freeOrgSeatLimitReachedValidator(
organization: Organization,
allOrganizationUserEmails: string[],
errorMessage: string
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === "" || !control.value) {
return null;
}
const newEmailsToAdd = control.value
.split(",")
.filter(
(newEmailToAdd: string) =>
newEmailToAdd &&
!allOrganizationUserEmails.some((existingEmail) => existingEmail === newEmailToAdd)
);
return organization.planProductType === ProductType.Free &&
allOrganizationUserEmails.length + newEmailsToAdd.length > organization.seats
? { freePlanLimitReached: { message: errorMessage } }
: null;
};
}

View File

@@ -317,7 +317,6 @@
</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>

View File

@@ -16,10 +16,10 @@ import {
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import {
SimpleDialogType,
DialogServiceAbstraction,
SimpleDialogCloseType,
SimpleDialogOptions,
SimpleDialogType,
} from "@bitwarden/angular/services/dialog";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -55,7 +55,7 @@ import { CollectionData } from "@bitwarden/common/vault/models/data/collection.d
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { EntityEventsComponent } from "../../../admin-console/organizations/manage/entity-events.component";
import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component";
import { BasePeopleComponent } from "../../../common/base.people.component";
import { GroupService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
@@ -81,8 +81,6 @@ export class PeopleComponent
{
@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 })
@@ -398,6 +396,7 @@ export class PeopleComponent
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
organizationUserId: user != null ? user.id : null,
allOrganizationUserEmails: this.allUsers?.map((user) => user.email) ?? [],
usesKeyConnector: user?.usesKeyConnector,
initialTab: initialTab,
},
@@ -513,12 +512,14 @@ export class PeopleComponent
}
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";
await openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,
entityId: user.id,
showUser: false,
entity: "user",
},
});
}
@@ -546,7 +547,7 @@ export class PeopleComponent
? "removeUserConfirmationKeyConnector"
: "removeOrgUserConfirmation";
return await this.dialogService.openSimpleDialog({
const confirmed = await this.dialogService.openSimpleDialog({
title: {
key: "removeUserIdAccess",
placeholders: [this.userNamePipe.transform(user)],
@@ -554,6 +555,35 @@ export class PeopleComponent
content: { key: content },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(),
acceptButtonText: { key: "revokeAccess" },
type: SimpleDialogType.WARNING,
});
if (!confirmed) {
return false;
}
if (user.status > OrganizationUserStatusType.Invited && user.hasMasterPassword === false) {
return await this.noMasterPasswordConfirmationDialog(user);
}
return true;
}
private async showBulkStatus(
@@ -608,4 +638,17 @@ export class PeopleComponent
modal.close();
}
}
private async noMasterPasswordConfirmationDialog(user: OrganizationUserView) {
return this.dialogService.openSimpleDialog({
title: {
key: "removeOrgUserNoMasterPasswordTitle",
},
content: {
key: "removeOrgUserNoMasterPasswordDesc",
placeholders: [this.userNamePipe.transform(user)],
},
type: SimpleDialogType.WARNING,
});
}
}

View File

@@ -1,95 +1,66 @@
<div class="page-header">
<h1>{{ "organizationInfo" | i18n }}</h1>
</div>
<h1 bitTypography="h1" class="tw-pb-2.5">{{ "organizationInfo" | i18n }}</h1>
<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>
<span class="tw-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 || !canEditSubscription"
/>
</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 || !canEditSubscription"
/>
</div>
<form *ngIf="org && !loading" #form [bitSubmit]="submit" [formGroup]="formGroup">
<div class="tw-grid tw-grid-cols-2 tw-gap-5">
<div>
<bit-form-field>
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput id="orgName" type="text" formControlName="orgName" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "billingEmail" | i18n }}</bit-label>
<input bitInput id="billingEmail" formControlName="billingEmail" type="email" />
</bit-form-field>
<bit-form-field>
<bit-label>{{ "businessName" | i18n }}</bit-label>
<input bitInput id="businessName" formControlName="businessName" type="text" />
</bit-form-field>
</div>
<div class="col-6">
<div>
<bit-avatar [text]="org.name" [id]="org.id" size="large"></bit-avatar>
<app-account-fingerprint
[fingerprintMaterial]="organizationId"
[publicKeyBuffer]="publicKeyBuffer"
fingerprintLabel="{{ 'yourOrganizationsFingerprint' | i18n }}"
>
</app-account-fingerprint>
</div>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
</form>
<ng-container *ngIf="canUseApi">
<div class="secondary-header border-0 mb-0">
<h1>{{ "apiKey" | i18n }}</h1>
</div>
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5">{{ "apiKey" | i18n }}</h1>
<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()">
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" class="btn btn-outline-secondary" (click)="rotateApiKey()">
<button type="button" bitButton buttonType="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>
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5">
<p>{{ "dangerZoneDesc" | i18n }}</p>
<button type="button" bitButton buttonType="danger" (click)="deleteOrganization()">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" bitButton buttonType="danger" (click)="purgeVault()">
{{ "purgeVault" | i18n }}
</button>
</div>
<ng-template #purgeOrganizationTemplate></ng-template>
<ng-template #apiKeyTemplate></ng-template>

View File

@@ -1,6 +1,7 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom } from "rxjs";
import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from } from "rxjs";
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -13,6 +14,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ApiKeyComponent } from "../../../settings/api-key.component";
import { PurgeVaultComponent } from "../../../settings/purge-vault.component";
@@ -23,7 +25,6 @@ import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./
selector: "app-org-account",
templateUrl: "account.component.html",
})
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
export class AccountComponent {
@ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true })
purgeModalRef: ViewContainerRef;
@@ -40,7 +41,29 @@ export class AccountComponent {
formPromise: Promise<OrganizationResponse>;
taxFormPromise: Promise<unknown>;
private organizationId: string;
// FormGroup validators taken from server Organization domain object
protected formGroup = this.formBuilder.group({
orgName: this.formBuilder.control(
{ value: "", disabled: true },
{
validators: [Validators.required, Validators.maxLength(50)],
updateOn: "change",
}
),
billingEmail: this.formBuilder.control(
{ value: "", disabled: true },
{ validators: [Validators.required, Validators.email, Validators.maxLength(256)] }
),
businessName: this.formBuilder.control(
{ value: "", disabled: true },
{ validators: [Validators.maxLength(50)] }
),
});
protected organizationId: string;
protected publicKeyBuffer: ArrayBuffer;
private destroy$ = new Subject<void>();
constructor(
private modalService: ModalService,
@@ -52,53 +75,88 @@ export class AccountComponent {
private router: Router,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
private dialogService: DialogServiceAbstraction
private dialogService: DialogServiceAbstraction,
private formBuilder: FormBuilder
) {}
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.canEditSubscription = this.organizationService.get(
this.organizationId
).canEditSubscription;
try {
this.org = await this.organizationApiService.get(this.organizationId);
this.canUseApi = this.org.useApi;
} catch (e) {
this.logService.error(e);
}
});
this.loading = false;
this.route.parent.parent.params
.pipe(
switchMap((params) => {
return combineLatest([
// Organization domain
this.organizationService.get$(params.organizationId),
// OrganizationResponse for form population
from(this.organizationApiService.get(params.organizationId)),
// Organization Public Key
from(this.organizationApiService.getKeys(params.organizationId)),
]);
}),
takeUntil(this.destroy$)
)
.subscribe(([organization, orgResponse, orgKeys]) => {
// Set domain level organization variables
this.organizationId = organization.id;
this.canEditSubscription = organization.canEditSubscription;
this.canUseApi = organization.useApi;
// Org Response
this.org = orgResponse;
// Public Key Buffer for Org Fingerprint Generation
this.publicKeyBuffer = Utils.fromB64ToArray(orgKeys?.publicKey)?.buffer;
// Patch existing values
this.formGroup.patchValue({
orgName: this.org.name,
billingEmail: this.org.billingEmail,
businessName: this.org.businessName,
});
// Update disabled states - reactive forms prefers not using disabled attribute
if (!this.selfHosted) {
this.formGroup.get("orgName").enable();
}
if (!this.selfHosted || this.canEditSubscription) {
this.formGroup.get("billingEmail").enable();
this.formGroup.get("businessName").enable();
}
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;
ngOnDestroy(): void {
// You must first call .next() in order for the notifier to properly close subscriptions using takeUntil
this.destroy$.next();
this.destroy$.complete();
}
// 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);
submit = async () => {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
}
const request = new OrganizationUpdateRequest();
request.name = this.formGroup.value.orgName;
request.businessName = this.formGroup.value.businessName;
request.billingEmail = this.formGroup.value.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"));
};
async deleteOrganization() {
const dialog = openDeleteOrganizationDialog(this.dialogService, {

View File

@@ -1,6 +1,7 @@
import { NgModule } from "@angular/core";
import { LooseComponentsModule, SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { PoliciesModule } from "../../organizations/policies";
import { AccountComponent } from "./account.component";
@@ -9,7 +10,13 @@ import { SettingsComponent } from "./settings.component";
import { TwoFactorSetupComponent } from "./two-factor-setup.component";
@NgModule({
imports: [SharedModule, LooseComponentsModule, PoliciesModule, OrganizationSettingsRoutingModule],
imports: [
SharedModule,
LooseComponentsModule,
PoliciesModule,
OrganizationSettingsRoutingModule,
AccountFingerprintComponent,
],
declarations: [SettingsComponent, AccountComponent, TwoFactorSetupComponent],
})
export class OrganizationSettingsModule {}

View File

@@ -60,6 +60,16 @@
>
{{ "singleSignOn" | i18n }}
</a>
<ng-container *appIfFeature="FeatureFlag.TrustedDeviceEncryption">
<a
routerLink="device-approvals"
class="list-group-item"
routerLinkActive="active"
*ngIf="org.canManageUsersPassword"
>
{{ "deviceApprovals" | i18n }}
</a>
</ng-container>
<a
routerLink="scim"
class="list-group-item"

View File

@@ -4,6 +4,7 @@ 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";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@Component({
selector: "app-org-settings",
@@ -11,6 +12,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
})
export class SettingsComponent implements OnInit {
organization$: Observable<Organization>;
FeatureFlag = FeatureFlag;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}

View File

@@ -247,13 +247,17 @@ export abstract class BasePeopleComponent<
this.actionPromise = null;
}
async revoke(user: UserType) {
const confirmed = await this.dialogService.openSimpleDialog({
protected async revokeUserConfirmationDialog(user: UserType) {
return this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(),
acceptButtonText: { key: "revokeAccess" },
type: SimpleDialogType.WARNING,
});
}
async revoke(user: UserType) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;

View File

@@ -1,6 +1,6 @@
<router-outlet></router-outlet>
<div class="container my-5 text-muted text-center">
<div class="tw-mb-1">
<div class="tw-mb-1" *ngIf="!isSelfHosted">
<bit-menu #environmentOptions>
<a bitMenuItem href="https://vault.bitwarden.com" class="pr-4">
<i

View File

@@ -11,9 +11,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
})
export class FrontendLayoutComponent implements OnInit, OnDestroy {
version: string;
isSelfHosted: boolean;
euServerFlagEnabled: boolean;
year = "2015";
isEuServer = true;
euServerFlagEnabled: boolean;
constructor(
private platformUtilsService: PlatformUtilsService,
@@ -23,6 +24,7 @@ export class FrontendLayoutComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.year = new Date().getFullYear().toString();
this.version = await this.platformUtilsService.getApplicationVersion();
this.isSelfHosted = this.platformUtilsService.isSelfHost();
this.euServerFlagEnabled = await this.configService.getFeatureFlagBool(
FeatureFlag.DisplayEuEnvironmentFlag
);

View File

@@ -46,19 +46,11 @@
Customize
</button>
</div>
<hr />
<p *ngIf="fingerprint">
{{ "yourAccountsFingerprint" | i18n }}:
<a
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i></a
><br />
<code>{{ fingerprint }}</code>
</p>
<app-account-fingerprint
[fingerprintMaterial]="fingerprintMaterial"
fingerprintLabel="{{ 'yourAccountsFingerprint' | i18n }}"
>
</app-account-fingerprint>
</div>
</div>
<button type="submit" class="btn btn-primary btn-submit" [disabled]="form.loading">

View File

@@ -3,10 +3,8 @@ import { Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request";
import { ProfileResponse } from "@bitwarden/common/models/response/profile.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -21,7 +19,7 @@ import { ChangeAvatarComponent } from "./change-avatar.component";
export class ProfileComponent implements OnInit, OnDestroy {
loading = true;
profile: ProfileResponse;
fingerprint: string;
fingerprintMaterial: string;
formPromise: Promise<any>;
@ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true })
@@ -32,9 +30,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService,
private logService: LogService,
private keyConnectorService: KeyConnectorService,
private stateService: StateService,
private modalService: ModalService
) {}
@@ -42,12 +38,7 @@ export class ProfileComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.profile = await this.apiService.getProfile();
this.loading = false;
const fingerprint = await this.cryptoService.getFingerprint(
await this.stateService.getUserId()
);
if (fingerprint != null) {
this.fingerprint = fingerprint.join("-");
}
this.fingerprintMaterial = await this.stateService.getUserId();
}
async ngOnDestroy() {

View File

@@ -0,0 +1,16 @@
<ng-container>
<hr />
<p *ngIf="fingerprint">
{{ fingerprintLabel }}:
<a
href="https://bitwarden.com/help/fingerprint-phrase/"
target="_blank"
rel="noopener"
appA11yTitle="{{ 'learnMore' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i
></a>
<br />
<code class="tw-text-code">{{ fingerprint }}</code>
</p>
</ng-container>

View File

@@ -0,0 +1,30 @@
import { Component, Input, OnInit } from "@angular/core";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { SharedModule } from "../../shared.module";
@Component({
selector: "app-account-fingerprint",
templateUrl: "account-fingerprint.component.html",
standalone: true,
imports: [SharedModule],
})
export class AccountFingerprintComponent implements OnInit {
@Input() fingerprintMaterial: string;
@Input() publicKeyBuffer: ArrayBuffer;
@Input() fingerprintLabel: string;
protected fingerprint: string;
constructor(private cryptoService: CryptoService) {}
async ngOnInit() {
// TODO - In the future, remove this code and use the fingerprint pipe once merged
const generatedFingerprint = await this.cryptoService.getFingerprint(
this.fingerprintMaterial,
this.publicKeyBuffer
);
this.fingerprint = generatedFingerprint?.join("-") ?? null;
}
}

View File

@@ -3,7 +3,6 @@ import { NgModule } from "@angular/core";
import { OrganizationSwitcherComponent } from "../admin-console/components/organization-switcher.component";
import { OrganizationCreateModule } from "../admin-console/organizations/create/organization-create.module";
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";
import { EntityEventsComponent as OrgEntityEventsComponent } from "../admin-console/organizations/manage/entity-events.component";
import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component";
import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component";
import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component";
@@ -108,6 +107,7 @@ import { AddEditComponent as OrgAddEditComponent } from "../vault/org-vault/add-
import { AttachmentsComponent as OrgAttachmentsComponent } from "../vault/org-vault/attachments.component";
import { CollectionsComponent as OrgCollectionsComponent } from "../vault/org-vault/collections.component";
import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component";
import { UserVerificationModule } from "./components/user-verification";
import { SharedModule } from "./shared.module";
@@ -122,6 +122,7 @@ import { SharedModule } from "./shared.module";
UserVerificationModule,
ChangeKdfModule,
DynamicAvatarComponent,
AccountFingerprintComponent,
],
declarations: [
PremiumBadgeComponent,
@@ -166,7 +167,6 @@ import { SharedModule } from "./shared.module";
OrganizationPlansComponent,
OrgAttachmentsComponent,
OrgCollectionsComponent,
OrgEntityEventsComponent,
OrgEventsComponent,
OrgExposedPasswordsReportComponent,
OrgInactiveTwoFactorReportComponent,
@@ -271,7 +271,6 @@ import { SharedModule } from "./shared.module";
OrganizationPlansComponent,
OrgAttachmentsComponent,
OrgCollectionsComponent,
OrgEntityEventsComponent,
OrgEventsComponent,
OrgExposedPasswordsReportComponent,
OrgInactiveTwoFactorReportComponent,

View File

@@ -96,5 +96,4 @@
<ng-template #attachments></ng-template>
<ng-template #cipherAddEdit></ng-template>
<ng-template #collectionsModal></ng-template>
<ng-template #eventsTemplate></ng-template>
</div>

View File

@@ -56,7 +56,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { Icons } from "@bitwarden/components";
import { GroupService, GroupView } from "../../admin-console/organizations/core";
import { EntityEventsComponent } from "../../admin-console/organizations/manage/entity-events.component";
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
import {
@@ -109,8 +109,6 @@ export class VaultComponent implements OnInit, OnDestroy {
cipherAddEditModalRef: ViewContainerRef;
@ViewChild("collectionsModal", { read: ViewContainerRef, static: true })
collectionsModalRef: ViewContainerRef;
@ViewChild("eventsTemplate", { read: ViewContainerRef, static: true })
eventsModalRef: ViewContainerRef;
trashCleanupWarning: string = null;
activeFilter: VaultFilter = new VaultFilter();
@@ -885,12 +883,14 @@ export class VaultComponent implements OnInit, OnDestroy {
}
async viewEvents(cipher: CipherView) {
await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => {
comp.name = cipher.name;
comp.organizationId = this.organization.id;
comp.entityId = cipher.id;
comp.showUser = true;
comp.entity = "cipher";
await openEntityEventsDialog(this.dialogService, {
data: {
name: cipher.name,
organizationId: this.organization.id,
entityId: cipher.id,
showUser: true,
entity: "cipher",
},
});
}