1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-26 06:13:46 +00:00

Merge branch 'main' of github.com:bitwarden/clients into vault/pm-28060/remove-skeleton-ff

This commit is contained in:
Nick Krantz
2026-01-21 13:58:28 -06:00
86 changed files with 4569 additions and 1393 deletions

View File

@@ -112,7 +112,7 @@ jobs:
npm_command: dist:bit:selfhost
- artifact_name: selfhosted-DEV
license_type: "commercial"
image_name: web
image_name: web-dev
npm_command: build:bit:selfhost:dev
git_metadata: true
- artifact_name: cloud-QA

View File

@@ -2473,6 +2473,9 @@
"permanentlyDeletedItem": {
"message": "Item permanently deleted"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"restoreItem": {
"message": "Restore item"
},

View File

@@ -1,3 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Message, MessageTypes } from "./message";
const SENDER = "bitwarden-webauthn";
@@ -23,7 +25,7 @@ type Handler = (
* handling aborts and exceptions across separate execution contexts.
*/
export class Messenger {
private messageEventListener: ((event: MessageEvent<MessageWithMetadata>) => void) | null = null;
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
private onDestroy = new EventTarget();
/**
@@ -58,12 +60,6 @@ export class Messenger {
this.broadcastChannel.addEventListener(this.messageEventListener);
}
private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message {
void SENDER;
void senderId;
return message;
}
/**
* Sends a request to the content script and returns the response.
* AbortController signals will be forwarded to the content script.
@@ -78,9 +74,7 @@ export class Messenger {
try {
const promise = new Promise<Message>((resolve) => {
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
resolve(this.stripMetadata(event.data));
};
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => resolve(event.data);
});
const abortListener = () =>
@@ -135,9 +129,7 @@ export class Messenger {
try {
const handlerResponse = await this.handler(message, abortController);
if (handlerResponse !== undefined) {
port.postMessage({ ...handlerResponse, SENDER });
}
port.postMessage({ ...handlerResponse, SENDER });
} catch (error) {
port.postMessage({
SENDER,

View File

@@ -91,10 +91,18 @@ describe("AutofillConfirmationDialogComponent", () => {
jest.resetAllMocks();
});
const findShowAll = (inFx?: ComponentFixture<AutofillConfirmationDialogComponent>) =>
(inFx || fixture).nativeElement.querySelector(
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
) as HTMLButtonElement | null;
const findShowAll = (inFx?: ComponentFixture<AutofillConfirmationDialogComponent>) => {
// Find the button by its text content (showAll or showLess)
const buttons = Array.from(
(inFx || fixture).nativeElement.querySelectorAll("button"),
) as HTMLButtonElement[];
return (
buttons.find((btn) => {
const text = btn.textContent?.trim() || "";
return text === "showAll" || text === "showLess";
}) || null
);
};
it("normalizes currentUrl and savedUrls via Utils.getHostname", () => {
expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0));

View File

@@ -18,7 +18,13 @@
{{ "emptyVaultDescription" | i18n }}
</p>
</ng-container>
<a slot="button" bitButton buttonType="secondary" [routerLink]="['/add-cipher']">
<a
slot="button"
bitButton
buttonType="secondary"
[routerLink]="['/add-cipher']"
[queryParams]="{ prefillNameAndURIFromTab: true }"
>
{{ "newLogin" | i18n }}
</a>
</bit-no-items>

View File

@@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent {
}
async restore(cipher: PopupCipherViewLike) {
let toastMessage;
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.restoreWithServer(cipher.id as string, activeUserId);
if (cipher.archivedDate) {
toastMessage = this.i18nService.t("archivedItemRestored");
} else {
toastMessage = this.i18nService.t("restoredItem");
}
await this.router.navigate(["/trash"]);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
message: toastMessage,
});
} catch (e) {
this.logService.error(e);

View File

@@ -2913,11 +2913,12 @@ dependencies = [
[[package]]
name = "serial_test"
version = "3.2.0"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555"
dependencies = [
"futures",
"futures-executor",
"futures-util",
"log",
"once_cell",
"parking_lot",
@@ -2927,9 +2928,9 @@ dependencies = [
[[package]]
name = "serial_test_derive"
version = "3.2.0"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -8,7 +8,7 @@ publish.workspace = true
[target.'cfg(windows)'.dependencies]
itertools.workspace = true
mockall = "=0.14.0"
serial_test = "=3.2.0"
serial_test = "=3.3.1"
tracing.workspace = true
windows = { workspace = true, features = [
"Win32_UI_Input_KeyboardAndMouse",

View File

@@ -15,8 +15,8 @@
"@bitwarden/storage-core": "file:../../../libs/storage-core",
"module-alias": "2.2.3",
"ts-node": "10.9.2",
"uuid": "13.0.0",
"yargs": "18.0.0"
"uuid": "9.0.1",
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.19.3",
@@ -121,7 +121,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -150,30 +149,6 @@
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -181,19 +156,83 @@
"license": "MIT"
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -209,12 +248,6 @@
"node": ">=0.3.1"
}
},
"node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -233,16 +266,12 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz",
"integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==",
"license": "MIT",
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": ">=8"
}
},
"node_modules/make-error": {
@@ -257,36 +286,49 @@
"integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
"node": ">=0.10.0"
}
},
"node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"license": "MIT",
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"ansi-regex": "^6.0.1"
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=12"
"node": ">=8"
}
},
"node_modules/string-width/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/string-width/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
"engines": {
"node": ">=8"
}
},
"node_modules/ts-node": {
@@ -337,7 +379,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
"integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -353,16 +394,15 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
@@ -371,23 +411,6 @@
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz",
"integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -398,29 +421,28 @@
}
},
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dependencies": {
"cliui": "^9.0.1",
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
"yargs-parser": "^21.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
"node": ">=12"
}
},
"node_modules/yn": {

View File

@@ -20,8 +20,8 @@
"@bitwarden/logging": "dist/libs/logging/src",
"module-alias": "2.2.3",
"ts-node": "10.9.2",
"uuid": "13.0.0",
"yargs": "18.0.0"
"uuid": "9.0.1",
"yargs": "17.7.2"
},
"devDependencies": {
"@types/node": "22.19.3",
@@ -31,6 +31,12 @@
"@bitwarden/common": "dist/libs/common/src",
"@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service",
"@bitwarden/storage-core": "dist/libs/storage-core/src",
"@bitwarden/logging": "dist/libs/logging/src"
"@bitwarden/logging": "dist/libs/logging/src",
"@bitwarden/client-type": "dist/libs/client-type/src",
"@bitwarden/state": "dist/libs/state/src",
"@bitwarden/state-internal": "dist/libs/state-internal/src",
"@bitwarden/messaging": "dist/libs/messaging/src",
"@bitwarden/guid": "dist/libs/guid/src",
"@bitwarden/serialization": "dist/libs/serialization/src"
}
}

View File

@@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { CredentialCreatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-create-payload";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
const argv: any = yargs(hideBin(process.argv)).option("name", {
@@ -25,6 +26,10 @@ const { name } = argv;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
// Handshake
LogUtils.logInfo("Sending Handshake");
@@ -42,7 +47,10 @@ const { name } = argv;
// Get active account userId
const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey);
const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0];
const activeUser = status.payload.filter(
(a: { active: boolean; status: string; id: string }) =>
a.active === true && a.status === "unlocked",
)[0];
if (activeUser === undefined) {
LogUtils.logError("No active or unlocked user");
}

View File

@@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
const argv: any = yargs(hideBin(process.argv)).option("uri", {
@@ -21,6 +22,10 @@ const { uri } = argv;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
// Handshake
LogUtils.logInfo("Sending Handshake");

View File

@@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { CredentialUpdatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-update-payload";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
// Command line arguments
@@ -49,6 +50,10 @@ const { name, username, password, uri, credentialId } = argv;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
// Handshake
LogUtils.logInfo("Sending Handshake");
@@ -67,7 +72,10 @@ const { name, username, password, uri, credentialId } = argv;
// Get active account userId
const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey);
const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0];
const activeUser = status.payload.filter(
(a: { active: boolean; status: string; id: string }) =>
a.active === true && a.status === "unlocked",
)[0];
if (activeUser === undefined) {
LogUtils.logError("No active or unlocked user");
}

View File

@@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
const argv: any = yargs(hideBin(process.argv)).option("userId", {
@@ -21,6 +22,10 @@ const { userId } = argv;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
// Handshake
LogUtils.logInfo("Sending Handshake");

View File

@@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
const response = await nativeMessageService.sendHandshake(

View File

@@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums";
import { LogUtils } from "../log-utils";
import NativeMessageService from "../native-message.service";
import { TestRunnerSdkLoadService } from "../sdk-load.service";
import * as config from "../variables";
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
// Initialize SDK before using crypto functions
const sdkLoadService = new TestRunnerSdkLoadService();
await sdkLoadService.loadAndInit();
const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One);
LogUtils.logInfo("Sending Handshake");

View File

@@ -4,8 +4,8 @@
// while allowing an unrelated event to fulfill it elsewhere.
export default class Deferred<T> {
private promise: Promise<T>;
private resolver: (T?) => void;
private rejecter: (Error?) => void;
private resolver!: (value?: T) => void;
private rejecter!: (reason?: Error) => void;
constructor() {
this.promise = new Promise<T>((resolve, reject) => {

View File

@@ -13,7 +13,7 @@ import { race } from "./race";
const DEFAULT_MESSAGE_TIMEOUT = 10 * 1000; // 10 seconds
export type MessageHandler = (MessageCommon) => void;
export type MessageHandler = (message: MessageCommon) => void;
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums

View File

@@ -8,8 +8,8 @@ export const race = <T>({
promise: Promise<T>;
timeout: number;
error?: Error;
}) => {
let timer = null;
}): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
// Similar to Promise.all, but instead of waiting for all, it resolves once one promise finishes.
// Using this so we can reject if the timeout threshold is hit
@@ -20,7 +20,9 @@ export const race = <T>({
}),
promise.then((value) => {
clearTimeout(timer);
if (timer != null) {
clearTimeout(timer);
}
return value;
}),
]);

View File

@@ -0,0 +1,22 @@
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { LogUtils } from "./log-utils";
/**
* SDK Load Service for the native messaging test runner.
* For Node.js environments, the SDK's Node.js build automatically loads WASM from the filesystem.
* No additional initialization is needed.
*/
export class TestRunnerSdkLoadService extends SdkLoadService {
async load(): Promise<void> {
// In Node.js, @bitwarden/sdk-internal automatically loads the WASM file
// from node/bitwarden_wasm_internal_bg.wasm using fs.readFileSync.
// No explicit loading is required.
}
override async loadAndInit(): Promise<void> {
LogUtils.logInfo("Initializing SDK");
await super.loadAndInit();
LogUtils.logSuccess("SDK initialized");
}
}

View File

@@ -1,4 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
@@ -18,7 +19,13 @@
"@bitwarden/auth/*": ["../../../libs/auth/src/*"],
"@bitwarden/common/*": ["../../../libs/common/src/*"],
"@bitwarden/key-management": ["../../../libs/key-management/src/"],
"@bitwarden/node/*": ["../../../libs/node/src/*"]
"@bitwarden/node/*": ["../../../libs/node/src/*"],
"@bitwarden/state": ["../../../libs/state/src/index.ts"],
"@bitwarden/state-internal": ["../../../libs/state-internal/src/index.ts"],
"@bitwarden/client-type": ["../../../libs/client-type/src/index.ts"],
"@bitwarden/messaging": ["../../../libs/messaging/src/index.ts"],
"@bitwarden/guid": ["../../../libs/guid/src/index.ts"],
"@bitwarden/serialization": ["../../../libs/serialization/src/index.ts"]
},
"plugins": [
{
@@ -26,5 +33,6 @@
}
]
},
"exclude": ["node_modules"]
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -2092,6 +2092,9 @@
"permanentlyDeletedItem": {
"message": "Item permanently deleted"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"restoredItem": {
"message": "Item restored"
},

View File

@@ -12,7 +12,7 @@ import { VaultFilter, CollectionFilter } from "@bitwarden/vault";
imports: [A11yTitleDirective, NavigationModule],
})
export class CollectionFilterComponent {
protected readonly collection = input<TreeNode<CollectionFilter>>();
protected readonly collection = input.required<TreeNode<CollectionFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly displayName = computed<string>(() => {

View File

@@ -13,7 +13,7 @@ import { VaultFilter, FolderFilter } from "@bitwarden/vault";
imports: [A11yTitleDirective, NavigationModule, IconButtonModule, I18nPipe],
})
export class FolderFilterComponent {
protected readonly folder = input<TreeNode<FolderFilter>>();
protected readonly folder = input.required<TreeNode<FolderFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected onEditFolder = output<FolderFilter>();

View File

@@ -1,3 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, computed, input, inject } from "@angular/core";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
@@ -20,7 +22,7 @@ export class OrganizationFilterComponent {
private vaultFilterService: VaultFilterServiceAbstraction = inject(VaultFilterServiceAbstraction);
protected readonly hide = input(false);
protected readonly organizations = input<TreeNode<OrganizationFilter>>();
protected readonly organizations = input.required<TreeNode<OrganizationFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly activeOrganizationDataOwnership = input<boolean>(false);
protected readonly activeSingleOrganizationPolicy = input<boolean>(false);
@@ -56,7 +58,6 @@ export class OrganizationFilterComponent {
if (!organization.node.enabled) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("disabledOrganizationFilterError"),
});
return;

View File

@@ -6,10 +6,13 @@
[text]="archiveFilter.name | i18n"
[attr.aria-pressed]="activeFilter()?.isArchived"
[appA11yTitle]="archiveFilter.name | i18n"
/>
@if (!(canArchive$ | async)) {
<app-premium-badge />
}
>
@if (!(canArchive$ | async)) {
<ng-container slot="end">
<app-premium-badge />
</ng-container>
}
</bit-nav-item>
}
<bit-nav-item
[icon]="trashFilter.icon"

View File

@@ -1,3 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, viewChild, input, inject } from "@angular/core";
import { combineLatest, firstValueFrom, map, switchMap } from "rxjs";
@@ -23,7 +25,10 @@ export class StatusFilterComponent {
private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService);
protected readonly hideArchive = input(false);
protected readonly activeFilter = input<VaultFilter>();
protected readonly activeFilter = input.required<VaultFilter>();
private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent);
protected readonly archiveFilter: CipherTypeFilter = {
id: "archive",
name: "archiveNoun",
@@ -38,7 +43,7 @@ export class StatusFilterComponent {
};
protected applyFilter(filterType: CipherStatus) {
let filter: CipherTypeFilter = null;
let filter: CipherTypeFilter | null = null;
if (filterType === "archive") {
filter = this.archiveFilter;
} else if (filterType === "trash") {
@@ -50,8 +55,6 @@ export class StatusFilterComponent {
}
}
private readonly premiumBadgeComponent = viewChild.required(PremiumBadgeComponent);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected canArchive$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
@@ -71,7 +74,7 @@ export class StatusFilterComponent {
if (canArchive || hasArchivedCiphers) {
this.applyFilter("archive");
} else {
await this.premiumBadgeComponent().promptForPremium(event);
await this.premiumBadgeComponent()?.promptForPremium(event);
}
}
}

View File

@@ -20,7 +20,7 @@ export class TypeFilterComponent {
RestrictedItemTypesService,
);
protected readonly cipherTypes = input<TreeNode<CipherTypeFilter>>();
protected readonly cipherTypes = input.required<TreeNode<CipherTypeFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected applyTypeFilter(event: Event, cipherType: TreeNode<CipherTypeFilter>) {

View File

@@ -1,3 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit, output, computed, signal } from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";

View File

@@ -1,3 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";

View File

@@ -173,16 +173,23 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
async restore(): Promise<boolean> {
let toastMessage;
if (!this.cipher.isDeleted) {
return false;
}
if (this.cipher.isArchived) {
toastMessage = this.i18nService.t("archivedItemRestored");
} else {
toastMessage = this.i18nService.t("restoredItem");
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.restoreCipher(activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredItem"),
message: toastMessage,
});
this.onRestore.emit(this.cipher);
} catch (e) {
@@ -239,6 +246,9 @@ export class ItemFooterComponent implements OnInit, OnChanges {
// A user should always be able to unarchive an archived item
this.showUnarchiveButton =
hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived;
hasArchiveFlagEnabled &&
this.action === "view" &&
this.cipher.isArchived &&
!this.cipher.isDeleted;
}
}

View File

@@ -30,14 +30,14 @@
</span>
</li>
<li
class="filter-option tw-flex tw-items-center tw-gap-2 [&>span]:tw-w-min"
class="filter-option tw-flex tw-items-center tw-gap-2 [&>span]:tw-w-fit"
[ngClass]="{ active: activeFilter.status === 'archive' }"
*ngIf="!hideArchive"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
class="filter-button !tw-max-w-none"
(click)="handleArchiveFilter($event)"
[attr.aria-pressed]="activeFilter.status === 'archive'"
>

View File

@@ -611,7 +611,7 @@ export class VaultV2Component<C extends CipherViewLike>
});
}
if (cipher.isArchived) {
if (cipher.isArchived && !cipher.isDeleted) {
menu.push({
label: this.i18nService.t("unArchive"),
click: async () => {

View File

@@ -2,18 +2,24 @@
// @ts-strict-ignore
import { computed, Signal } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
import { Observable, Subject, map } from "rxjs";
import {
OrganizationUserStatusType,
ProviderUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { TableDataSource } from "@bitwarden/components";
import { StatusType, UserViewTypes } from "./base-members.component";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
export type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
export type UserViewTypes = ProviderUser | OrganizationUserView;
export type ProviderUser = ProviderUserUserDetailsResponse;
/**
* Default maximum for most bulk operations (confirm, remove, delete, etc.)
@@ -100,6 +106,8 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
this.data?.filter((u) => u.status === this.statusType.Confirmed).length ?? 0;
this.revokedUserCount =
this.data?.filter((u) => u.status === this.statusType.Revoked).length ?? 0;
this.checkedUsersUpdated$.next();
}
override get data() {
@@ -112,6 +120,15 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
* @param select check the user (true), uncheck the user (false), or toggle the current state (null)
*/
checkUser(user: T, select?: boolean) {
this.setUserChecked(user, select);
this.checkedUsersUpdated$.next();
}
/**
* Internal method to set checked state without triggering emissions.
* Use this in bulk operations to avoid excessive emissions.
*/
private setUserChecked(user: T, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
}
@@ -119,6 +136,12 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
return this.data.filter((u) => (u as any).checked);
}
private checkedUsersUpdated$ = new Subject<void>();
usersUpdated(): Observable<T[]> {
return this.checkedUsersUpdated$.asObservable().pipe(map(() => this.getCheckedUsers()));
}
/**
* Gets checked users in the order they appear in the filtered/sorted table view.
* Use this when enforcing limits to ensure visual consistency (top N visible rows stay checked).
@@ -147,8 +170,10 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
: Math.min(filteredUsers.length, MaxCheckedCount);
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
this.setUserChecked(filteredUsers[i], select);
}
this.checkedUsersUpdated$.next();
}
uncheckAllUsers() {
@@ -190,7 +215,10 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
}
// Uncheck users beyond the limit
users.slice(limit).forEach((user) => this.checkUser(user, false));
users.slice(limit).forEach((user) => this.setUserChecked(user, false));
// Emit once after all unchecking is done
this.checkedUsersUpdated$.next();
return users.slice(0, limit);
}
@@ -213,3 +241,26 @@ export abstract class PeopleTableDataSource<T extends UserViewTypes> extends Tab
}
}
}
export class ProvidersTableDataSource extends PeopleTableDataSource<ProviderUser> {
protected statusType = ProviderUserStatusType;
}
export class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
}
/**
* Helper function to determine if the confirm users banner should be shown
* @params dataSource Either a ProvidersTableDataSource or a MembersTableDataSource
*/
export function showConfirmBanner(
dataSource: ProvidersTableDataSource | MembersTableDataSource,
): boolean {
return (
dataSource.activeUserCount > 1 &&
dataSource.confirmedUserCount > 0 &&
dataSource.confirmedUserCount < 3 &&
dataSource.acceptedUserCount > 0
);
}

View File

@@ -2,8 +2,11 @@
// @ts-strict-ignore
import { Component, Inject, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -14,7 +17,8 @@ export type UserConfirmDialogData = {
name: string;
userId: string;
publicKey: Uint8Array;
confirmUser: (publicKey: Uint8Array) => Promise<void>;
// @TODO remove this when doing feature flag cleanup for members component refactor.
confirmUser?: (publicKey: Uint8Array) => Promise<void>;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -42,6 +46,7 @@ export class UserConfirmComponent implements OnInit {
private keyService: KeyService,
private logService: LogService,
private organizationManagementPreferencesService: OrganizationManagementPreferencesService,
private configService: ConfigService,
) {
this.name = data.name;
this.userId = data.userId;
@@ -64,16 +69,21 @@ export class UserConfirmComponent implements OnInit {
submit = async () => {
if (this.loading) {
return;
return false;
}
if (this.formGroup.value.dontAskAgain) {
await this.organizationManagementPreferencesService.autoConfirmFingerPrints.set(true);
}
await this.data.confirmUser(this.publicKey);
const membersComponentRefactorEnabled = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.MembersComponentRefactor),
);
if (!membersComponentRefactorEnabled) {
await this.data.confirmUser(this.publicKey);
}
this.dialogRef.close();
this.dialogRef.close(true);
};
static open(dialogService: DialogService, config: DialogConfig<UserConfirmDialogData>) {

View File

@@ -36,6 +36,7 @@ type BulkConfirmDialogParams = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-confirm-dialog.component.html",
selector: "member-bulk-comfirm-dialog",
standalone: false,
})
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {

View File

@@ -20,6 +20,7 @@ type BulkDeleteDialogParams = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-delete-dialog.component.html",
selector: "member-bulk-delete-dialog",
standalone: false,
})
export class BulkDeleteDialogComponent {

View File

@@ -24,6 +24,7 @@ export type BulkEnableSecretsManagerDialogData = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: `bulk-enable-sm-dialog.component.html`,
selector: "member-bulk-enable-sm-dialog",
standalone: false,
})
export class BulkEnableSecretsManagerDialogComponent implements OnInit {

View File

@@ -23,6 +23,7 @@ type BulkRemoveDialogParams = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "bulk-remove-dialog.component.html",
selector: "member-bulk-remove-dialog",
standalone: false,
})
export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {

View File

@@ -18,7 +18,7 @@ type BulkRestoreDialogParams = {
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-bulk-restore-revoke",
selector: "member-bulk-restore-revoke",
templateUrl: "bulk-restore-revoke.component.html",
standalone: false,
})

View File

@@ -41,7 +41,7 @@ type BulkStatusDialogData = {
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-bulk-status",
selector: "member-bulk-status",
templateUrl: "bulk-status.component.html",
standalone: false,
})

View File

@@ -0,0 +1,495 @@
@let organization = this.organization();
@if (organization) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls()"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="bulkReinvite(organization)"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm(organization)"
*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(organization)"
*ngIf="showBulkRestoreUsers"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRevoke(organization)"
*ngIf="showBulkRevokeUsers"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr
bitRow
*cdkVirtualFor="let u of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
<td bitCell (click)="edit(u, organization)" 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 class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="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>
</ng-container>
<ng-template #readOnlyUserInfo>
<td bitCell>
<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 class="tw-flex tw-flex-row tw-gap-2">
<span>{{ u.name ?? u.email }}</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="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>
</ng-template>
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
<td
bitCell
(click)="
edit(
u,
organization,
organization.useGroups ? memberTab.Groups : memberTab.Collections
)
"
class="tw-cursor-pointer"
>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-container>
<ng-template #readOnlyGroupsCell>
<td bitCell>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
[maxItems]="3"
variant="secondary"
></bit-badge-list>
</td>
</ng-template>
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
<td
bitCell
(click)="edit(u, organization, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
</ng-container>
<ng-template #readOnlyRoleCell>
<td bitCell class="tw-text-sm tw-text-muted">
{{ u.type | userType }}
</td>
</ng-template>
<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>
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<div class="tw-w-[32px]"></div>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="reinvite(u, organization)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u, organization)"
*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, organization, memberTab.Role)"
>
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Collections)"
>
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u, organization)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
</ng-container>
<!-- Account recovery is available to all users with appropriate permissions -->
<button
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="restore(u, organization)"
*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, organization)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u, organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}

View File

@@ -0,0 +1,616 @@
import { Component, computed, Signal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
concatMap,
filter,
firstValueFrom,
from,
map,
merge,
Observable,
shareReplay,
switchMap,
take,
} from "rxjs";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { PolicyApiServiceAbstraction } 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,
OrganizationUserType,
PolicyType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { getById } from "@bitwarden/common/platform/misc";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
PeopleTableDataSource,
} from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
MemberActionResult,
} from "./services/member-actions/member-actions.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deprecated_members.component.html",
standalone: false,
})
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource: MembersTableDataSource;
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
resetPasswordPolicyEnabled$: Observable<boolean>;
protected readonly canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
);
protected readonly showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
constructor(
apiService: ApiService,
i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
keyService: KeyService,
validationService: ValidationService,
logService: LogService,
userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private route: ActivatedRoute,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationWarningsService: OrganizationWarningsService,
private memberActionsService: MemberActionsService,
private memberDialogManager: MemberDialogManagerService,
protected billingConstraint: BillingConstraintService,
protected memberService: OrganizationMembersService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
const organization$ = this.route.params.pipe(
concatMap((params) =>
this.userId$.pipe(
switchMap((userId) =>
this.organizationService.organizations$(userId).pipe(getById(params.organizationId)),
),
filter((organization): organization is Organization => organization != null),
shareReplay({ refCount: true, bufferSize: 1 }),
),
),
);
this.organization = toSignal(organization$);
const policies$ = combineLatest([this.userId$, organization$]).pipe(
switchMap(([userId, organization]) =>
organization.isProviderUser
? from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
)
: this.policyService.policies$(userId),
),
);
this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe(
map(
([organization, policies]) =>
policies
.filter((policy) => policy.type === PolicyType.ResetPassword)
.find((p) => p.organizationId === organization.id)?.enabled ?? false,
),
);
combineLatest([this.route.queryParams, organization$])
.pipe(
concatMap(async ([qParams, organization]) => {
await this.load(organization!);
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.openEventsDialog(user[0], organization!);
}
}
}),
takeUntilDestroyed(),
)
.subscribe();
organization$
.pipe(
switchMap((organization) =>
merge(
this.organizationWarningsService.showInactiveSubscriptionDialog$(organization),
this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization),
),
),
takeUntilDestroyed(),
)
.subscribe();
this.billingMetadata$ = organization$.pipe(
switchMap((organization) =>
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
// Stripe is slow, so kick this off in the background but without blocking page load.
// Anyone who needs it will still await the first emission.
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
}
override async load(organization: Organization) {
await super.load(organization);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
return await this.memberService.loadUsers(organization);
}
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.removeUser(organization, id);
}
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.revokeUser(organization, id);
}
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.restoreUser(organization, id);
}
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.reinviteUser(organization, id);
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
return await this.memberActionsService.confirmUser(user, publicKey, organization);
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
allowResetPassword(
orgUser: OrganizationUserView,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return this.memberActionsService.allowResetPassword(
orgUser,
organization,
orgResetPasswordPolicyEnabled,
);
}
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
return (
organization.useResetPassword &&
orgUser.resetPasswordEnrolled &&
orgResetPasswordPolicyEnabled
);
}
private async handleInviteDialog(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
const result = await this.memberDialogManager.openInviteDialog(
organization,
billingMetadata,
allUserEmails,
);
if (result === MemberDialogResult.Saved) {
await this.load(organization);
}
}
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
await this.handleInviteDialog(organization);
this.organizationMetadataService.refreshMetadataCache();
}
}
async edit(
user: OrganizationUserView,
organization: Organization,
initialTab: MemberDialogTab = MemberDialogTab.Role,
) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const result = await this.memberDialogManager.openEditDialog(
user,
organization,
billingMetadata,
initialTab,
);
switch (result) {
case MemberDialogResult.Deleted:
this.dataSource.removeUser(user);
break;
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
case MemberDialogResult.Restored:
await this.load(organization);
break;
}
}
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
async bulkDelete(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
await this.load(organization);
}
async bulkRevoke(organization: Organization) {
await this.bulkRevokeOrRestore(true, organization);
}
async bulkRestore(organization: Organization) {
await this.bulkRevokeOrRestore(false, organization);
}
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
await this.load(organization);
}
async bulkReinvite(organization: Organization) {
if (this.actionPromise != null) {
return;
}
let users: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
filteredUsers = allInvitedUsers;
}
if (filteredUsers.length <= 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
try {
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
if (!result.successful) {
throw new Error();
}
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
async bulkConfirm(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
this.dataSource.uncheckAllUsers();
await this.load(organization);
}
openEventsDialog(user: OrganizationUserView, organization: Organization) {
this.memberDialogManager.openEventsDialog(user, organization);
}
async resetPassword(user: OrganizationUserView, organization: Organization) {
if (!user || !user.email || !user.id) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("orgUserDetailsNotFound"),
});
this.logService.error("Org user details not found when attempting account recovery");
return;
}
const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization);
if (result === AccountRecoveryDialogResultType.Ok) {
await this.load(organization);
}
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
}
async deleteUser(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
user,
organization,
);
if (!confirmed) {
return false;
}
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
try {
const result = await this.actionPromise;
if (!result.success) {
throw new Error(result.error);
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
}
get showBulkRestoreUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Revoked);
}
get showBulkRevokeUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status != this.userStatusType.Revoked);
}
get showBulkRemoveUsers(): boolean {
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
}
get showBulkDeleteUsers(): boolean {
const validStatuses = [
this.userStatusType.Accepted,
this.userStatusType.Confirmed,
this.userStatusType.Revoked,
];
return this.dataSource
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
exportMembers = () => {
const result = this.memberExportService.getMemberExport(this.dataSource.data);
if (result.success) {
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
}
if (result.error != null) {
this.validationService.showError(result.error.message);
}
};
}

View File

@@ -1,23 +1,30 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard";
import { MembersComponent } from "./members.component";
import { MembersComponent } from "./deprecated_members.component";
import { vNextMembersComponent } from "./members.component";
const routes: Routes = [
{
path: "",
component: MembersComponent,
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
data: {
titleId: "members",
...featureFlaggedRoute({
defaultComponent: MembersComponent,
flaggedComponent: vNextMembersComponent,
featureFlag: FeatureFlag.MembersComponentRefactor,
routeOptions: {
path: "",
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
data: {
titleId: "members",
},
},
},
}),
{
path: "sponsored-families",
component: FreeBitwardenFamiliesComponent,

View File

@@ -1,5 +1,10 @@
@let organization = this.organization();
@if (organization) {
@let dataSource = this.dataSource();
@let bulkActions = bulkMenuOptions$ | async;
@let showConfirmBanner = showConfirmBanner$ | async;
@let isProcessing = this.isProcessing();
@if (organization && dataSource) {
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="billingConstraint.navigateToPaymentMethod(organization)"
@@ -12,183 +17,199 @@
[placeholder]="'searchMembers' | i18n"
></bit-search>
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded"
*ngIf="showUserManagementControls()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
@if (showUserManagementControls()) {
<button
type="button"
bitButton
buttonType="primary"
(click)="invite(organization)"
[disabled]="!firstLoaded()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
}
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
*ngIf="showUserManagementControls()"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">{{
allCount
}}</span>
</bit-toggle>
@if (showUserManagementControls()) {
<bit-toggle-group
[selected]="statusToggle | async"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="undefined">
{{ "all" | i18n }}
@if (dataSource.activeUserCount; as allCount) {
<span bitBadge variant="info">{{ allCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">{{
invitedCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
@if (dataSource.invitedUserCount; as invitedCount) {
<span bitBadge variant="info">{{ invitedCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedUserCount">{{
acceptedUserCount
}}</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
@if (dataSource.acceptedUserCount; as acceptedUserCount) {
<span bitBadge variant="info">{{ acceptedUserCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.revokedUserCount as revokedCount">{{
revokedCount
}}</span>
</bit-toggle>
</bit-toggle-group>
<bit-toggle [value]="userStatusType.Revoked">
{{ "revoked" | i18n }}
@if (dataSource.revokedUserCount; as revokedCount) {
<span bitBadge variant="info">{{ revokedCount }}</span>
}
</bit-toggle>
</bit-toggle-group>
}
</div>
<ng-container *ngIf="!firstLoaded">
@if (!firstLoaded() || !organization || !dataSource) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
} @else {
@if (!dataSource.filteredData?.length) {
<p>{{ "noMembersInList" | i18n }}</p>
}
@if (dataSource.filteredData?.length) {
@if (showConfirmBanner) {
<bit-callout type="info" title="{{ 'confirmUsers' | i18n }}" icon="bwi-check-circle">
{{ "usersNeedConfirmed" | i18n }}
</bit-callout>
}
<!-- The padding on the bottom of the cdk-virtual-scroll-viewport element is required to prevent table row content
from overflowing the <main> element. -->
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20" *ngIf="showUserManagementControls()">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
@if (showUserManagementControls()) {
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
}
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
*ngIf="showUserManagementControls()"
></button>
</div>
<th bitCell class="tw-w-10">
@if (showUserManagementControls()) {
<th bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
<button
type="button"
bitIconButton="bwi-download"
size="small"
[bitAction]="exportMembers"
[disabled]="!firstLoaded"
label="{{ 'export' | i18n }}"
></button>
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
</div>
</th>
}
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager()">
<button type="button" bitMenuItem (click)="bulkEnableSM(organization)">
@if (canUseSecretsManager()) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkEnableSM(organization)"
>
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button
type="button"
bitMenuItem
(click)="bulkReinvite(organization)"
*ngIf="showBulkReinviteUsers"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm(organization)"
*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(organization)"
*ngIf="showBulkRestoreUsers"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRevoke(organization)"
*ngIf="showBulkRevokeUsers"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkRemove(organization)"
*ngIf="showBulkRemoveUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="bulkDelete(organization)"
*ngIf="showBulkDeleteUsers"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkReinviteUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkReinvite(organization)"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
}
@if (bulkActions.showBulkConfirmUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkConfirm(organization)"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkRestoreUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkRevokeOrRestore(false, organization)"
>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
}
@if (bulkActions.showBulkRevokeUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkRevokeOrRestore(true, organization)"
>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
}
@if (bulkActions.showBulkRemoveUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkRemove(organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
}
@if (bulkActions.showBulkDeleteUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkDelete(organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-fw bwi-trash"></i>
{{ "delete" | i18n }}
</span>
</button>
}
</bit-menu>
</th>
</tr>
@@ -200,10 +221,10 @@
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(u)" *ngIf="showUserManagementControls()">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<ng-container *ngIf="showUserManagementControls(); else readOnlyUserInfo">
@if (showUserManagementControls()) {
<td bitCell (click)="dataSource.checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="u.checked" />
</td>
<td bitCell (click)="edit(u, organization)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
@@ -218,39 +239,31 @@
<button type="button" bitLink>
{{ u.name ?? u.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
@if (u.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (u.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (u.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (u.name) {
<div class="tw-text-sm tw-text-muted">
{{ u.email }}
</div>
}
</div>
</div>
</td>
</ng-container>
<ng-template #readOnlyUserInfo>
} @else {
<td bitCell>
<div class="tw-flex tw-items-center">
<bit-avatar
@@ -263,40 +276,33 @@
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-row tw-gap-2">
<span>{{ u.name ?? u.email }}</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="u.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="u.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="u.name">
{{ u.email }}
@if (u.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (u.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (u.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (u.name) {
<div class="tw-text-sm tw-text-muted">
{{ u.email }}
</div>
}
</div>
</div>
</td>
</ng-template>
}
<ng-container *ngIf="showUserManagementControls(); else readOnlyGroupsCell">
@if (showUserManagementControls()) {
<td
bitCell
(click)="
@@ -314,8 +320,7 @@
variant="secondary"
></bit-badge-list>
</td>
</ng-container>
<ng-template #readOnlyGroupsCell>
} @else {
<td bitCell>
<bit-badge-list
[items]="organization.useGroups ? u.groupNames : u.collectionNames"
@@ -323,9 +328,9 @@
variant="secondary"
></bit-badge-list>
</td>
</ng-template>
}
<ng-container *ngIf="showUserManagementControls(); else readOnlyRoleCell">
@if (showUserManagementControls()) {
<td
bitCell
(click)="edit(u, organization, memberTab.Role)"
@@ -333,33 +338,30 @@
>
{{ u.type | userType }}
</td>
</ng-container>
<ng-template #readOnlyRoleCell>
} @else {
<td bitCell class="tw-text-sm tw-text-muted">
{{ u.type | userType }}
</td>
</ng-template>
}
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
@if (u.twoFactorEnabled) {
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
}
@let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async;
<ng-container
*ngIf="showEnrolledStatus($any(u), organization, resetPasswordPolicyEnabled)"
>
@if (showEnrolledStatus(u, organization, resetPasswordPolicyEnabled)) {
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
}
</td>
<td bitCell>
<div class="tw-flex tw-flex-row tw-items-center tw-justify-end tw-gap-2">
@@ -374,122 +376,131 @@
</div>
<bit-menu #rowMenu>
<ng-container *ngIf="showUserManagementControls()">
@if (showUserManagementControls()) {
@if (u.status === userStatusType.Invited) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : reinvite(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
}
@if (u.status === userStatusType.Accepted) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : confirm(u, organization)"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
}
@if (
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
) {
<bit-menu-divider></bit-menu-divider>
}
<button
type="button"
bitMenuItem
(click)="reinvite(u, organization)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u, organization)"
*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, organization, memberTab.Role)"
(click)="isProcessing ? null : edit(u, organization, memberTab.Role)"
>
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
@if (organization.useGroups) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : edit(u, organization, memberTab.Groups)"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
}
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, organization, memberTab.Collections)"
(click)="isProcessing ? null : edit(u, organization, memberTab.Collections)"
>
<i aria-hidden="true" class="bwi bwi-collection-shared"></i>
{{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u, organization)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
</ng-container>
@if (organization.useEvents && u.status === userStatusType.Confirmed) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : openEventsDialog(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i>
{{ "eventLogs" | i18n }}
</button>
}
}
<!-- Account recovery is available to all users with appropriate permissions -->
<button
type="button"
bitMenuItem
(click)="resetPassword(u, organization)"
*ngIf="allowResetPassword(u, organization, resetPasswordPolicyEnabled)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
@if (allowResetPassword(u, organization, resetPasswordPolicyEnabled)) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : resetPassword(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
}
<ng-container *ngIf="showUserManagementControls()">
<button
type="button"
bitMenuItem
(click)="restore(u, organization)"
*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, organization)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button
*ngIf="!u.managedByOrganization"
type="button"
bitMenuItem
(click)="remove(u, organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
<button
*ngIf="u.managedByOrganization"
type="button"
bitMenuItem
(click)="deleteUser(u, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</ng-container>
@if (showUserManagementControls()) {
@if (u.status === userStatusType.Revoked) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : restore(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
}
@if (u.status !== userStatusType.Revoked) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : revoke(u, organization)"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
}
@if (!u.managedByOrganization) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : remove(u, organization)"
>
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
} @else {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : deleteUser(u, organization)"
>
<span class="tw-text-danger">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
}
}
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}
}
}

View File

@@ -0,0 +1,696 @@
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { PolicyApiServiceAbstraction } 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,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService, ToastService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { OrganizationUserView } from "../core/views/organization-user.view";
import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component";
import { MemberDialogResult } from "./components/member-dialog";
import { vNextMembersComponent } from "./members.component";
import {
MemberDialogManagerService,
MemberExportService,
OrganizationMembersService,
} from "./services";
import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service";
import {
MemberActionsService,
MemberActionResult,
} from "./services/member-actions/member-actions.service";
describe("vNextMembersComponent", () => {
let component: vNextMembersComponent;
let fixture: ComponentFixture<vNextMembersComponent>;
let mockApiService: MockProxy<ApiService>;
let mockI18nService: MockProxy<I18nService>;
let mockOrganizationManagementPreferencesService: MockProxy<OrganizationManagementPreferencesService>;
let mockKeyService: MockProxy<KeyService>;
let mockValidationService: MockProxy<ValidationService>;
let mockLogService: MockProxy<LogService>;
let mockUserNamePipe: MockProxy<UserNamePipe>;
let mockDialogService: MockProxy<DialogService>;
let mockToastService: MockProxy<ToastService>;
let mockActivatedRoute: ActivatedRoute;
let mockDeleteManagedMemberWarningService: MockProxy<DeleteManagedMemberWarningService>;
let mockOrganizationWarningsService: MockProxy<OrganizationWarningsService>;
let mockMemberActionsService: MockProxy<MemberActionsService>;
let mockMemberDialogManager: MockProxy<MemberDialogManagerService>;
let mockBillingConstraint: MockProxy<BillingConstraintService>;
let mockMemberService: MockProxy<OrganizationMembersService>;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockAccountService: FakeAccountService;
let mockPolicyService: MockProxy<PolicyService>;
let mockPolicyApiService: MockProxy<PolicyApiServiceAbstraction>;
let mockOrganizationMetadataService: MockProxy<OrganizationMetadataServiceAbstraction>;
let mockConfigService: MockProxy<ConfigService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockMemberExportService: MockProxy<MemberExportService>;
let mockFileDownloadService: MockProxy<FileDownloadService>;
let routeParamsSubject: BehaviorSubject<any>;
let queryParamsSubject: BehaviorSubject<any>;
const mockUserId = newGuid() as UserId;
const mockOrgId = newGuid() as OrganizationId;
const mockOrg = {
id: mockOrgId,
name: "Test Organization",
enabled: true,
canManageUsers: true,
useSecretsManager: true,
useResetPassword: true,
isProviderUser: false,
} as Organization;
const mockUser = {
id: newGuid(),
userId: newGuid(),
type: OrganizationUserType.User,
status: OrganizationUserStatusType.Confirmed,
email: "test@example.com",
name: "Test User",
resetPasswordEnrolled: false,
accessSecretsManager: false,
managedByOrganization: false,
twoFactorEnabled: false,
usesKeyConnector: false,
hasMasterPassword: true,
} as OrganizationUserView;
const mockBillingMetadata = {
isSubscriptionUnpaid: false,
} as Partial<OrganizationBillingMetadataResponse>;
beforeEach(async () => {
routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId });
queryParamsSubject = new BehaviorSubject({});
mockActivatedRoute = {
params: routeParamsSubject.asObservable(),
queryParams: queryParamsSubject.asObservable(),
} as any;
mockApiService = mock<ApiService>();
mockI18nService = mock<I18nService>();
mockI18nService.t.mockImplementation((key: string) => key);
mockOrganizationManagementPreferencesService = mock<OrganizationManagementPreferencesService>();
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = {
state$: of(false),
} as any;
mockKeyService = mock<KeyService>();
mockValidationService = mock<ValidationService>();
mockLogService = mock<LogService>();
mockUserNamePipe = mock<UserNamePipe>();
mockUserNamePipe.transform.mockReturnValue("Test User");
mockDialogService = mock<DialogService>();
mockToastService = mock<ToastService>();
mockDeleteManagedMemberWarningService = mock<DeleteManagedMemberWarningService>();
mockOrganizationWarningsService = mock<OrganizationWarningsService>();
mockMemberActionsService = mock<MemberActionsService>();
mockMemberDialogManager = mock<MemberDialogManagerService>();
mockBillingConstraint = mock<BillingConstraintService>();
mockMemberService = mock<OrganizationMembersService>();
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(of([mockOrg]));
mockAccountService = mockAccountServiceWith(mockUserId);
mockPolicyService = mock<PolicyService>();
mockPolicyApiService = mock<PolicyApiServiceAbstraction>();
mockOrganizationMetadataService = mock<OrganizationMetadataServiceAbstraction>();
mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue(
of(mockBillingMetadata),
);
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
mockEnvironmentService = mock<EnvironmentService>();
mockEnvironmentService.environment$ = of({
isCloud: () => false,
} as any);
mockMemberExportService = mock<MemberExportService>();
mockFileDownloadService = mock<FileDownloadService>();
await TestBed.configureTestingModule({
declarations: [vNextMembersComponent],
providers: [
{ provide: ApiService, useValue: mockApiService },
{ provide: I18nService, useValue: mockI18nService },
{
provide: OrganizationManagementPreferencesService,
useValue: mockOrganizationManagementPreferencesService,
},
{ provide: KeyService, useValue: mockKeyService },
{ provide: ValidationService, useValue: mockValidationService },
{ provide: LogService, useValue: mockLogService },
{ provide: UserNamePipe, useValue: mockUserNamePipe },
{ provide: DialogService, useValue: mockDialogService },
{ provide: ToastService, useValue: mockToastService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{
provide: DeleteManagedMemberWarningService,
useValue: mockDeleteManagedMemberWarningService,
},
{ provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService },
{ provide: MemberActionsService, useValue: mockMemberActionsService },
{ provide: MemberDialogManagerService, useValue: mockMemberDialogManager },
{ provide: BillingConstraintService, useValue: mockBillingConstraint },
{ provide: OrganizationMembersService, useValue: mockMemberService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService },
{
provide: OrganizationMetadataServiceAbstraction,
useValue: mockOrganizationMetadataService,
},
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EnvironmentService, useValue: mockEnvironmentService },
{ provide: MemberExportService, useValue: mockMemberExportService },
{ provide: FileDownloadService, useValue: mockFileDownloadService },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(vNextMembersComponent, {
remove: { imports: [] },
add: { template: "<div></div>" },
})
.compileComponents();
fixture = TestBed.createComponent(vNextMembersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
if (fixture) {
fixture.destroy();
}
jest.restoreAllMocks();
});
describe("load", () => {
it("should load users and set data source", async () => {
const users = [mockUser];
mockMemberService.loadUsers.mockResolvedValue(users);
await component.load(mockOrg);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
expect(component["dataSource"]().data).toEqual(users);
expect(component["firstLoaded"]()).toBe(true);
});
it("should handle empty response", async () => {
mockMemberService.loadUsers.mockResolvedValue([]);
await component.load(mockOrg);
expect(component["dataSource"]().data).toEqual([]);
});
});
describe("remove", () => {
it("should remove user when confirmed", async () => {
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
mockMemberActionsService.removeUser.mockResolvedValue({ success: true });
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
await component.remove(mockUser, mockOrg);
expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith(
mockUser,
);
expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
expect(removeSpy).toHaveBeenCalledWith(mockUser);
expect(mockToastService.showToast).toHaveBeenCalled();
});
it("should not remove user when not confirmed", async () => {
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false);
const result = await component.remove(mockUser, mockOrg);
expect(result).toBe(false);
expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled();
});
it("should handle errors via handleMemberActionResult", async () => {
mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true);
mockMemberActionsService.removeUser.mockResolvedValue({
success: false,
error: "Remove failed",
});
await component.remove(mockUser, mockOrg);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "Remove failed",
});
expect(mockLogService.error).toHaveBeenCalledWith("Remove failed");
});
});
describe("reinvite", () => {
it("should reinvite user successfully", async () => {
mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true });
await component.reinvite(mockUser, mockOrg);
expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
expect(mockToastService.showToast).toHaveBeenCalled();
});
it("should handle errors via handleMemberActionResult", async () => {
mockMemberActionsService.reinviteUser.mockResolvedValue({
success: false,
error: "Reinvite failed",
});
await component.reinvite(mockUser, mockOrg);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "Reinvite failed",
});
expect(mockLogService.error).toHaveBeenCalledWith("Reinvite failed");
});
});
describe("confirm", () => {
it("should confirm user with auto-confirm enabled", async () => {
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
mockMemberActionsService.confirmUser.mockResolvedValue({ success: true });
// Mock getPublicKeyForConfirm to return a public key
const mockPublicKey = new Uint8Array([1, 2, 3, 4]);
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(mockPublicKey);
const replaceSpy = jest.spyOn(component["dataSource"](), "replaceUser");
await component.confirm(mockUser, mockOrg);
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalledWith(mockUser);
expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith(
mockUser,
mockPublicKey,
mockOrg,
);
expect(replaceSpy).toHaveBeenCalled();
expect(mockToastService.showToast).toHaveBeenCalled();
});
it("should handle null user", async () => {
mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true);
// Mock getPublicKeyForConfirm to return null
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
await component.confirm(mockUser, mockOrg);
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled();
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
});
it("should handle API errors gracefully", async () => {
// Mock getPublicKeyForConfirm to return null
mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null);
await component.confirm(mockUser, mockOrg);
expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled();
expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found");
});
});
describe("revoke", () => {
it("should revoke user when confirmed", async () => {
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true);
mockMemberActionsService.revokeUser.mockResolvedValue({ success: true });
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.revoke(mockUser, mockOrg);
expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith(
mockUser,
);
expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
expect(mockToastService.showToast).toHaveBeenCalled();
});
it("should not revoke user when not confirmed", async () => {
mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false);
const result = await component.revoke(mockUser, mockOrg);
expect(result).toBe(false);
expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled();
});
});
describe("restore", () => {
it("should restore user successfully", async () => {
mockMemberActionsService.restoreUser.mockResolvedValue({ success: true });
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.restore(mockUser, mockOrg);
expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
expect(mockToastService.showToast).toHaveBeenCalled();
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
it("should handle errors via handleMemberActionResult", async () => {
mockMemberActionsService.restoreUser.mockResolvedValue({
success: false,
error: "Restore failed",
});
await component.restore(mockUser, mockOrg);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "Restore failed",
});
expect(mockLogService.error).toHaveBeenCalledWith("Restore failed");
});
});
describe("invite", () => {
it("should open invite dialog when seat limit not reached", async () => {
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
await component.invite(mockOrg);
expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith(
mockOrg,
mockBillingMetadata,
);
expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith(
mockOrg,
mockBillingMetadata,
expect.any(Array),
);
});
it("should reload organization and refresh metadata cache after successful invite", async () => {
mockBillingConstraint.seatLimitReached.mockResolvedValue(false);
mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.invite(mockOrg);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
});
it("should not open dialog when seat limit reached", async () => {
mockBillingConstraint.seatLimitReached.mockResolvedValue(true);
await component.invite(mockOrg);
expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled();
});
});
describe("bulkRemove", () => {
it("should open bulk remove dialog and reload", async () => {
const users = [mockUser];
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.bulkRemove(mockOrg);
expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users);
expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled();
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
});
describe("bulkDelete", () => {
it("should open bulk delete dialog and reload", async () => {
const users = [mockUser];
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.bulkDelete(mockOrg);
expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
});
describe("bulkRevokeOrRestore", () => {
it.each([
{ isRevoking: true, action: "revoke" },
{ isRevoking: false, action: "restore" },
])(
"should open bulk $action dialog and reload when isRevoking is $isRevoking",
async ({ isRevoking }) => {
const users = [mockUser];
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.bulkRevokeOrRestore(isRevoking, mockOrg);
expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith(
mockOrg,
users,
isRevoking,
);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
},
);
});
describe("bulkReinvite", () => {
it("should reinvite invited users", async () => {
const invitedUser = {
...mockUser,
status: OrganizationUserStatusType.Invited,
};
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true });
await component.bulkReinvite(mockOrg);
expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]);
expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled();
});
it("should show error when no invited users selected", async () => {
const confirmedUser = {
...mockUser,
status: OrganizationUserStatusType.Confirmed,
};
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([confirmedUser]);
await component.bulkReinvite(mockOrg);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: "errorOccurred",
message: "noSelectedUsersApplicable",
});
expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled();
});
it("should handle errors", async () => {
const invitedUser = {
...mockUser,
status: OrganizationUserStatusType.Invited,
};
jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false);
jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]);
const error = new Error("Bulk reinvite failed");
mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error });
await component.bulkReinvite(mockOrg);
expect(mockValidationService.showError).toHaveBeenCalledWith(error);
});
});
describe("bulkConfirm", () => {
it("should open bulk confirm dialog and reload", async () => {
const users = [mockUser];
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.bulkConfirm(mockOrg);
expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
});
describe("bulkEnableSM", () => {
it("should open bulk enable SM dialog and reload", async () => {
const users = [mockUser];
jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users);
jest.spyOn(component["dataSource"](), "uncheckAllUsers");
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.bulkEnableSM(mockOrg);
expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith(
mockOrg,
users,
);
expect(component["dataSource"]().uncheckAllUsers).toHaveBeenCalled();
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
});
describe("resetPassword", () => {
it("should open account recovery dialog", async () => {
mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue(
AccountRecoveryDialogResultType.Ok,
);
mockMemberService.loadUsers.mockResolvedValue([mockUser]);
await component.resetPassword(mockUser, mockOrg);
expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith(
mockUser,
mockOrg,
);
expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg);
});
});
describe("deleteUser", () => {
it("should delete user when confirmed", async () => {
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
mockMemberActionsService.deleteUser.mockResolvedValue({ success: true });
const removeSpy = jest.spyOn(component["dataSource"](), "removeUser");
await component.deleteUser(mockUser, mockOrg);
expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith(
mockUser,
mockOrg,
);
expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id);
expect(removeSpy).toHaveBeenCalledWith(mockUser);
expect(mockToastService.showToast).toHaveBeenCalled();
});
it("should not delete user when not confirmed", async () => {
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false);
const result = await component.deleteUser(mockUser, mockOrg);
expect(result).toBe(false);
expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled();
});
it("should handle errors via handleMemberActionResult", async () => {
mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true);
mockMemberActionsService.deleteUser.mockResolvedValue({
success: false,
error: "Delete failed",
});
await component.deleteUser(mockUser, mockOrg);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "Delete failed",
});
expect(mockLogService.error).toHaveBeenCalledWith("Delete failed");
});
});
describe("handleMemberActionResult", () => {
it("should show success toast when result is successful", async () => {
const result: MemberActionResult = { success: true };
await component.handleMemberActionResult(result, "testSuccessKey", mockUser);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "testSuccessKey",
});
});
it("should execute side effect when provided and successful", async () => {
const result: MemberActionResult = { success: true };
const sideEffect = jest.fn();
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
expect(sideEffect).toHaveBeenCalled();
});
it("should show error toast when result is not successful", async () => {
const result: MemberActionResult = { success: false, error: "Error message" };
const sideEffect = jest.fn();
await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "Error message",
});
expect(mockLogService.error).toHaveBeenCalledWith("Error message");
expect(sideEffect).not.toHaveBeenCalled();
});
it("should propagate error when side effect throws", async () => {
const result: MemberActionResult = { success: true };
const error = new Error("Side effect failed");
const sideEffect = jest.fn().mockRejectedValue(error);
await expect(
component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect),
).rejects.toThrow("Side effect failed");
});
});
});

View File

@@ -1,9 +1,12 @@
import { Component, computed, Signal } from "@angular/core";
import { Component, computed, inject, signal, Signal, WritableSignal } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
concatMap,
debounceTime,
filter,
firstValueFrom,
from,
@@ -15,11 +18,8 @@ import {
take,
} from "rxjs";
import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
@@ -35,22 +35,21 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { getById } from "@bitwarden/common/platform/misc";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service";
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
PeopleTableDataSource,
MembersTableDataSource,
peopleFilter,
showConfirmBanner,
} from "../../common/people-table-data-source";
import { OrganizationUserView } from "../core/views/organization-user.view";
@@ -67,8 +66,13 @@ import {
MemberActionResult,
} from "./services/member-actions/member-actions.service";
class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView> {
protected statusType = OrganizationUserStatusType;
interface BulkMemberFlags {
showBulkRestoreUsers: boolean;
showBulkRevokeUsers: boolean;
showBulkRemoveUsers: boolean;
showBulkDeleteUsers: boolean;
showBulkConfirmUsers: boolean;
showBulkReinviteUsers: boolean;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -77,71 +81,76 @@ class MembersTableDataSource extends PeopleTableDataSource<OrganizationUserView>
templateUrl: "members.component.html",
standalone: false,
})
export class MembersComponent extends BaseMembersComponent<OrganizationUserView> {
userType = OrganizationUserType;
userStatusType = OrganizationUserStatusType;
memberTab = MemberDialogTab;
protected dataSource: MembersTableDataSource;
readonly organization: Signal<Organization | undefined>;
status: OrganizationUserStatusType | undefined;
export class vNextMembersComponent {
protected i18nService = inject(I18nService);
protected validationService = inject(ValidationService);
protected logService = inject(LogService);
protected userNamePipe = inject(UserNamePipe);
protected dialogService = inject(DialogService);
protected toastService = inject(ToastService);
private route = inject(ActivatedRoute);
protected deleteManagedMemberWarningService = inject(DeleteManagedMemberWarningService);
private organizationWarningsService = inject(OrganizationWarningsService);
private memberActionsService = inject(MemberActionsService);
private memberDialogManager = inject(MemberDialogManagerService);
protected billingConstraint = inject(BillingConstraintService);
protected memberService = inject(OrganizationMembersService);
private organizationService = inject(OrganizationService);
private accountService = inject(AccountService);
private policyService = inject(PolicyService);
private policyApiService = inject(PolicyApiServiceAbstraction);
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private configService = inject(ConfigService);
private environmentService = inject(EnvironmentService);
private memberExportService = inject(MemberExportService);
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
resetPasswordPolicyEnabled$: Observable<boolean>;
protected userType = OrganizationUserType;
protected userStatusType = OrganizationUserStatusType;
protected memberTab = MemberDialogTab;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<OrganizationUserStatusType | undefined>(undefined);
protected readonly dataSource: Signal<MembersTableDataSource> = signal(
new MembersTableDataSource(this.configService, this.environmentService),
);
protected readonly organization: Signal<Organization | undefined>;
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
protected bulkMenuOptions$ = this.dataSource()
.usersUpdated()
.pipe(map((members) => this.bulkMenuOptions(members)));
protected showConfirmBanner$ = this.dataSource()
.usersUpdated()
.pipe(map(() => showConfirmBanner(this.dataSource())));
protected isProcessing = this.memberActionsService.isProcessing;
protected readonly canUseSecretsManager: Signal<boolean> = computed(
() => this.organization()?.useSecretsManager ?? false,
);
protected readonly showUserManagementControls: Signal<boolean> = computed(
() => this.organization()?.canManageUsers ?? false,
);
protected billingMetadata$: Observable<OrganizationBillingMetadataResponse>;
protected resetPasswordPolicyEnabled$: Observable<boolean>;
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 66;
protected rowHeightClass = `tw-h-[66px]`;
constructor(
apiService: ApiService,
i18nService: I18nService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
keyService: KeyService,
validationService: ValidationService,
logService: LogService,
userNamePipe: UserNamePipe,
dialogService: DialogService,
toastService: ToastService,
private route: ActivatedRoute,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private organizationWarningsService: OrganizationWarningsService,
private memberActionsService: MemberActionsService,
private memberDialogManager: MemberDialogManagerService,
protected billingConstraint: BillingConstraintService,
protected memberService: OrganizationMembersService,
private organizationService: OrganizationService,
private accountService: AccountService,
private policyService: PolicyService,
private policyApiService: PolicyApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
private memberExportService: MemberExportService,
private fileDownloadService: FileDownloadService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
constructor() {
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(takeUntilDestroyed())
.subscribe(
([searchText, status]) => (this.dataSource().filter = peopleFilter(searchText, status)),
);
const organization$ = this.route.params.pipe(
concatMap((params) =>
@@ -184,7 +193,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents);
const user = this.dataSource().data.filter((u) => u.id === qParams.viewEvents);
if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) {
this.openEventsDialog(user[0], organization!);
}
@@ -218,80 +227,62 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe();
}
override async load(organization: Organization) {
await super.load(organization);
async load(organization: Organization) {
const response = await this.memberService.loadUsers(organization);
this.dataSource().data = response;
this.firstLoaded.set(true);
}
async getUsers(organization: Organization): Promise<OrganizationUserView[]> {
return await this.memberService.loadUsers(organization);
}
async removeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.removeUser(organization, id);
}
async revokeUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.revokeUser(organization, id);
}
async restoreUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.restoreUser(organization, id);
}
async reinviteUser(id: string, organization: Organization): Promise<MemberActionResult> {
return await this.memberActionsService.reinviteUser(organization, id);
}
async confirmUser(
user: OrganizationUserView,
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
return await this.memberActionsService.confirmUser(user, publicKey, organization);
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.revokeUserConfirmationDialog(user);
async remove(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
const result = await this.memberActionsService.removeUser(organization, user.id);
const sideEffect = () => this.dataSource().removeUser(user);
await this.handleMemberActionResult(result, "removedUserId", user, sideEffect);
}
async reinvite(user: OrganizationUserView, organization: Organization) {
const result = await this.memberActionsService.reinviteUser(organization, user.id);
await this.handleMemberActionResult(result, "hasBeenReinvited", user);
}
async confirm(user: OrganizationUserView, organization: Organization) {
const confirmUserSideEffect = () => {
user.status = this.userStatusType.Confirmed;
this.dataSource().replaceUser(user);
};
const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user);
if (publicKeyResult == null) {
this.logService.warning("Public key not found");
return;
}
this.actionPromise = undefined;
const result = await this.memberActionsService.confirmUser(user, publicKeyResult, organization);
await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect);
}
async revoke(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
const result = await this.memberActionsService.revokeUser(organization, user.id);
const sideEffect = async () => await this.load(organization);
await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect);
}
async restore(user: OrganizationUserView, organization: Organization) {
this.actionPromise = this.restoreUser(user.id, organization);
try {
const result = await this.actionPromise;
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
});
await this.load(organization);
} else {
throw new Error(result.error);
}
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = undefined;
const result = await this.memberActionsService.restoreUser(organization, user.id);
const sideEffect = async () => await this.load(organization);
await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect);
}
allowResetPassword(
@@ -307,7 +298,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
showEnrolledStatus(
orgUser: OrganizationUserUserDetailsResponse,
orgUser: OrganizationUserView,
organization: Organization,
orgResetPasswordPolicyEnabled: boolean,
): boolean {
@@ -318,9 +309,15 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
);
}
private async handleInviteDialog(organization: Organization) {
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? [];
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
if (await this.billingConstraint.seatLimitReached(seatLimitResult, organization)) {
return;
}
const allUserEmails = this.dataSource().data?.map((user) => user.email) ?? [];
const result = await this.memberDialogManager.openInviteDialog(
organization,
@@ -330,14 +327,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
if (result === MemberDialogResult.Saved) {
await this.load(organization);
}
}
async invite(organization: Organization) {
const billingMetadata = await firstValueFrom(this.billingMetadata$);
const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata);
if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) {
await this.handleInviteDialog(organization);
this.organizationMetadataService.refreshMetadataCache();
}
}
@@ -358,7 +347,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
switch (result) {
case MemberDialogResult.Deleted:
this.dataSource.removeUser(user);
this.dataSource().removeUser(user);
break;
case MemberDialogResult.Saved:
case MemberDialogResult.Revoked:
@@ -369,57 +358,30 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
}
async bulkRemove(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRemoveDialog(organization, users);
this.organizationMetadataService.refreshMetadataCache();
await this.load(organization);
}
async bulkDelete(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkDeleteDialog(organization, users);
await this.load(organization);
}
async bulkRevoke(organization: Organization) {
await this.bulkRevokeOrRestore(true, organization);
}
async bulkRestore(organization: Organization) {
await this.bulkRevokeOrRestore(false, organization);
}
async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking);
await this.load(organization);
}
async bulkReinvite(organization: Organization) {
if (this.actionPromise != null) {
return;
}
let users: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
users = this.dataSource().getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
users = this.dataSource().getCheckedUsers();
}
const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited);
@@ -429,8 +391,8 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
// When feature flag is enabled, limit invited users and uncheck the excess
let filteredUsers: OrganizationUserView[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource.limitAndUncheckExcess(
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
filteredUsers = this.dataSource().limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
@@ -447,70 +409,59 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
try {
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
const result = await this.memberActionsService.bulkReinvite(
organization,
filteredUsers.map((user) => user.id as UserId),
);
if (!result.successful) {
throw new Error();
}
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
} catch (e) {
this.validationService.showError(e);
if (!result.successful) {
this.validationService.showError(result.failed);
}
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
const selectedCount = originalInvitedCount;
const invitedCount = filteredUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
await this.memberDialogManager.openBulkStatusDialog(
users,
filteredUsers,
Promise.resolve(result.successful),
this.i18nService.t("bulkReinviteMessage"),
);
}
this.actionPromise = undefined;
}
async bulkConfirm(organization: Organization) {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkConfirmDialog(organization, users);
await this.load(organization);
}
async bulkEnableSM(organization: Organization) {
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users);
this.dataSource.uncheckAllUsers();
this.dataSource().uncheckAllUsers();
await this.load(organization);
}
@@ -538,14 +489,6 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return;
}
protected async removeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRemoveUserConfirmationDialog(user);
}
protected async revokeUserConfirmationDialog(user: OrganizationUserView) {
return await this.memberDialogManager.openRevokeUserConfirmationDialog(user);
}
async deleteUser(user: OrganizationUserView, organization: Organization) {
const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog(
user,
@@ -556,80 +499,72 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
return false;
}
this.actionPromise = this.memberActionsService.deleteUser(organization, user.id);
try {
const result = await this.actionPromise;
if (!result.success) {
throw new Error(result.error);
}
const result = await this.memberActionsService.deleteUser(organization, user.id);
await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => {
this.dataSource().removeUser(user);
});
}
async handleMemberActionResult(
result: MemberActionResult,
successKey: string,
user: OrganizationUserView,
sideEffect?: () => void | Promise<void>,
) {
if (result.error != null) {
this.toastService.showToast({
variant: "error",
message: this.i18nService.t(result.error),
});
this.logService.error(result.error);
return;
}
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)),
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
});
this.dataSource.removeUser(user);
} catch (e) {
this.validationService.showError(e);
if (sideEffect) {
await sideEffect();
}
}
this.actionPromise = undefined;
}
get showBulkRestoreUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status == this.userStatusType.Revoked);
}
get showBulkRevokeUsers(): boolean {
return this.dataSource
.getCheckedUsers()
.every((member) => member.status != this.userStatusType.Revoked);
}
get showBulkRemoveUsers(): boolean {
return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization);
}
get showBulkDeleteUsers(): boolean {
private bulkMenuOptions(members: OrganizationUserView[]): BulkMemberFlags {
const validStatuses = [
this.userStatusType.Accepted,
this.userStatusType.Confirmed,
this.userStatusType.Revoked,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
OrganizationUserStatusType.Revoked,
];
return this.dataSource
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
const result = {
showBulkConfirmUsers: members.every((m) => m.status == OrganizationUserStatusType.Accepted),
showBulkReinviteUsers: members.every((m) => m.status == OrganizationUserStatusType.Invited),
showBulkRestoreUsers: members.every((m) => m.status == OrganizationUserStatusType.Revoked),
showBulkRevokeUsers: members.every((m) => m.status != OrganizationUserStatusType.Revoked),
showBulkRemoveUsers: members.every((m) => !m.managedByOrganization),
showBulkDeleteUsers: members.every(
(m) => m.managedByOrganization && validStatuses.includes(m.status),
),
};
return result;
}
exportMembers = async (): Promise<void> => {
try {
const members = this.dataSource.data;
if (!members || members.length === 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noMembersToExport"),
});
return;
}
const csvData = this.memberExportService.getMemberExport(members);
const fileName = this.memberExportService.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
exportMembers = () => {
const result = this.memberExportService.getMemberExport(this.dataSource().data);
if (result.success) {
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("dataExportSuccess"),
});
} catch (e) {
this.validationService.showError(e);
this.logService.error(`Failed to export members: ${e}`);
}
if (result.error != null) {
this.validationService.showError(result.error.message);
}
};
}

View File

@@ -17,8 +17,9 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog.
import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component";
import { BulkStatusComponent } from "./components/bulk/bulk-status.component";
import { UserDialogModule } from "./components/member-dialog";
import { MembersComponent } from "./deprecated_members.component";
import { MembersRoutingModule } from "./members-routing.module";
import { MembersComponent } from "./members.component";
import { vNextMembersComponent } from "./members.component";
import { UserStatusPipe } from "./pipes";
import {
OrganizationMembersService,
@@ -46,6 +47,7 @@ import {
BulkRestoreRevokeComponent,
BulkStatusComponent,
MembersComponent,
vNextMembersComponent,
BulkDeleteDialogComponent,
UserStatusPipe,
],

View File

@@ -1,3 +1,4 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
@@ -6,6 +7,9 @@ import {
OrganizationUserBulkResponse,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import {
OrganizationUserType,
OrganizationUserStatusType,
@@ -14,8 +18,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
@@ -56,12 +63,29 @@ describe("MemberActionsService", () => {
resetPasswordEnrolled: true,
} as OrganizationUserView;
service = new MemberActionsService(
organizationUserApiService,
organizationUserService,
configService,
organizationMetadataService,
);
TestBed.configureTestingModule({
providers: [
MemberActionsService,
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
{ provide: OrganizationUserService, useValue: organizationUserService },
{ provide: ConfigService, useValue: configService },
{
provide: OrganizationMetadataServiceAbstraction,
useValue: organizationMetadataService,
},
{ provide: ApiService, useValue: mock<ApiService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: KeyService, useValue: mock<KeyService>() },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: OrganizationManagementPreferencesService,
useValue: mock<OrganizationManagementPreferencesService>(),
},
{ provide: UserNamePipe, useValue: mock<UserNamePipe>() },
],
});
service = TestBed.inject(MemberActionsService);
});
describe("inviteUser", () => {
@@ -660,4 +684,26 @@ describe("MemberActionsService", () => {
expect(result).toBe(false);
});
});
describe("isProcessing signal", () => {
it("should be false initially", () => {
expect(service.isProcessing()).toBe(false);
});
it("should be false after operation completes successfully", async () => {
organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined);
await service.removeUser(mockOrganization, userIdToManage);
expect(service.isProcessing()).toBe(false);
});
it("should be false after operation fails", async () => {
organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error("Failed"));
await service.removeUser(mockOrganization, userIdToManage);
expect(service.isProcessing()).toBe(false);
});
});
});

View File

@@ -1,23 +1,33 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { inject, Injectable, signal } from "@angular/core";
import { lastValueFrom, firstValueFrom } from "rxjs";
import {
OrganizationUserApiService,
OrganizationUserBulkResponse,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import {
OrganizationUserType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { OrganizationUserView } from "../../../core/views/organization-user.view";
import { UserConfirmComponent } from "../../../manage/user-confirm.component";
export const REQUESTS_PER_BATCH = 500;
@@ -33,12 +43,26 @@ export interface BulkActionResult {
@Injectable()
export class MemberActionsService {
constructor(
private organizationUserApiService: OrganizationUserApiService,
private organizationUserService: OrganizationUserService,
private configService: ConfigService,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
) {}
private organizationUserApiService = inject(OrganizationUserApiService);
private organizationUserService = inject(OrganizationUserService);
private configService = inject(ConfigService);
private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction);
private apiService = inject(ApiService);
private dialogService = inject(DialogService);
private keyService = inject(KeyService);
private logService = inject(LogService);
private orgManagementPrefs = inject(OrganizationManagementPreferencesService);
private userNamePipe = inject(UserNamePipe);
readonly isProcessing = signal(false);
private startProcessing(): void {
this.isProcessing.set(true);
}
private endProcessing(): void {
this.isProcessing.set(false);
}
async inviteUser(
organization: Organization,
@@ -48,6 +72,7 @@ export class MemberActionsService {
collections?: any[],
groups?: string[],
): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.postOrganizationUserInvite(organization.id, {
emails: [email],
@@ -60,55 +85,72 @@ export class MemberActionsService {
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async removeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.removeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async revokeUser(organization: Organization, userId: string): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async restoreUser(organization: Organization, userId: string): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async deleteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId);
this.organizationMetadataService.refreshMetadataCache();
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async reinviteUser(organization: Organization, userId: string): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
@@ -117,6 +159,7 @@ export class MemberActionsService {
publicKey: Uint8Array,
organization: Organization,
): Promise<MemberActionResult> {
this.startProcessing();
try {
await firstValueFrom(
this.organizationUserService.confirmUser(organization, user.id, publicKey),
@@ -124,27 +167,32 @@ export class MemberActionsService {
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> {
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
if (increaseBulkReinviteLimitForCloud) {
return await this.vNextBulkReinvite(organization, userIds);
} else {
try {
this.startProcessing();
try {
const increaseBulkReinviteLimitForCloud = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud),
);
if (increaseBulkReinviteLimitForCloud) {
return await this.vNextBulkReinvite(organization, userIds);
} else {
const result = await this.organizationUserApiService.postManyOrganizationUserReinvite(
organization.id,
userIds,
);
return { successful: result, failed: [] };
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
}
} catch (error) {
return {
failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })),
};
} finally {
this.endProcessing();
}
}
@@ -236,4 +284,50 @@ export class MemberActionsService {
failed: allFailed,
};
}
/**
* Shared dialog workflow that returns the public key when the user accepts the selected confirmation
* action.
*
* @param user - The user to confirm (must implement ConfirmableUser interface)
* @param userNamePipe - Pipe to transform user names for display
* @param orgManagementPrefs - Service providing organization management preferences
* @returns Promise containing the pulic key that resolves when the confirm action is accepted
* or undefined when cancelled
*/
async getPublicKeyForConfirm(
user: OrganizationUserView | ProviderUser,
): Promise<Uint8Array | undefined> {
try {
assertNonNullish(user, "Cannot confirm null user.");
const autoConfirmFingerPrint = await firstValueFrom(
this.orgManagementPrefs.autoConfirmFingerPrints.state$,
);
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
if (autoConfirmFingerPrint == null || !autoConfirmFingerPrint) {
const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey);
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
const confirmed = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user.userId,
publicKey: publicKey,
},
});
if (!(await lastValueFrom(confirmed.closed))) {
return;
}
}
return publicKey;
} catch (e) {
this.logService.error(`Handled exception: ${e}`);
}
}
}

View File

@@ -6,7 +6,9 @@ import {
OrganizationUserStatusType,
OrganizationUserType,
} from "@bitwarden/common/admin-console/enums";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
import { OrganizationUserView } from "../../../core";
import { UserStatusPipe } from "../../pipes";
@@ -16,9 +18,13 @@ import { MemberExportService } from "./member-export.service";
describe("MemberExportService", () => {
let service: MemberExportService;
let i18nService: MockProxy<I18nService>;
let fileDownloadService: MockProxy<FileDownloadService>;
let logService: MockProxy<LogService>;
beforeEach(() => {
i18nService = mock<I18nService>();
fileDownloadService = mock<FileDownloadService>();
logService = mock<LogService>();
// Setup common i18n translations
i18nService.t.mockImplementation((key: string) => {
@@ -44,9 +50,12 @@ describe("MemberExportService", () => {
custom: "Custom",
// Boolean states
enabled: "Enabled",
optionEnabled: "Enabled",
disabled: "Disabled",
enrolled: "Enrolled",
notEnrolled: "Not Enrolled",
// Error messages
noMembersToExport: "No members to export",
};
return translations[key] || key;
});
@@ -54,6 +63,8 @@ describe("MemberExportService", () => {
TestBed.configureTestingModule({
providers: [
MemberExportService,
{ provide: FileDownloadService, useValue: fileDownloadService },
{ provide: LogService, useValue: logService },
{ provide: I18nService, useValue: i18nService },
UserTypePipe,
UserStatusPipe,
@@ -88,8 +99,18 @@ describe("MemberExportService", () => {
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
const result = service.getMemberExport(members);
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(fileDownloadService.download).toHaveBeenCalledTimes(1);
const downloadCall = fileDownloadService.download.mock.calls[0][0];
expect(downloadCall.fileName).toContain("org-members");
expect(downloadCall.fileName).toContain(".csv");
expect(downloadCall.blobOptions).toEqual({ type: "text/plain" });
const csvData = downloadCall.blobData as string;
expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery");
expect(csvData).toContain("user1@example.com");
expect(csvData).toContain("User One");
@@ -114,8 +135,12 @@ describe("MemberExportService", () => {
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
const result = service.getMemberExport(members);
expect(result.success).toBe(true);
expect(fileDownloadService.download).toHaveBeenCalled();
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
expect(csvData).toContain("user@example.com");
// Empty name is represented as an empty field in CSV
expect(csvData).toContain("user@example.com,,Confirmed");
@@ -135,17 +160,23 @@ describe("MemberExportService", () => {
} as OrganizationUserView,
];
const csvData = service.getMemberExport(members);
const result = service.getMemberExport(members);
expect(result.success).toBe(true);
expect(fileDownloadService.download).toHaveBeenCalled();
const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string;
expect(csvData).toContain("user@example.com");
expect(csvData).toBeDefined();
});
it("should handle empty members array", () => {
const csvData = service.getMemberExport([]);
const result = service.getMemberExport([]);
// When array is empty, papaparse returns an empty string
expect(csvData).toBe("");
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error?.message).toBe("No members to export");
expect(fileDownloadService.download).not.toHaveBeenCalled();
});
});
});

View File

@@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core";
import * as papa from "papaparse";
import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ExportHelper } from "@bitwarden/vault-export-core";
import { OrganizationUserView } from "../../../core";
@@ -10,40 +12,71 @@ import { UserStatusPipe } from "../../pipes";
import { MemberExport } from "./member.export";
export interface MemberExportResult {
success: boolean;
error?: { message: string };
}
@Injectable()
export class MemberExportService {
private i18nService = inject(I18nService);
private userTypePipe = inject(UserTypePipe);
private userStatusPipe = inject(UserStatusPipe);
private fileDownloadService = inject(FileDownloadService);
private logService = inject(LogService);
getMemberExport(members: OrganizationUserView[]): string {
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
getMemberExport(data: OrganizationUserView[]): MemberExportResult {
try {
const members = data;
if (!members || members.length === 0) {
return { success: false, error: { message: this.i18nService.t("noMembersToExport") } };
}
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
const exportData = members.map((m) =>
MemberExport.fromOrganizationUserView(
this.i18nService,
this.userTypePipe,
this.userStatusPipe,
m,
),
);
return papa.unparse(exportData, {
columns: headers,
header: true,
});
const headers: string[] = [
this.i18nService.t("email"),
this.i18nService.t("name"),
this.i18nService.t("status"),
this.i18nService.t("role"),
this.i18nService.t("twoStepLogin"),
this.i18nService.t("accountRecovery"),
this.i18nService.t("secretsManager"),
this.i18nService.t("groups"),
];
const csvData = papa.unparse(exportData, {
columns: headers,
header: true,
});
const fileName = this.getFileName("org-members");
this.fileDownloadService.download({
fileName: fileName,
blobData: csvData,
blobOptions: { type: "text/plain" },
});
return { success: true };
} catch (error) {
this.logService.error(`Failed to export members: ${error}`);
const errorMessage =
error instanceof Error ? error.message : this.i18nService.t("unexpectedError");
return { success: false, error: { message: errorMessage } };
}
}
getFileName(prefix: string | null = null, extension = "csv"): string {
private getFileName(prefix: string | null = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix ?? "", extension);
}
}

View File

@@ -31,81 +31,75 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
<ng-container header *ngIf="!isAdminConsoleActive">
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</tr>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</ng-container>
<tbody>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<a
bitBadge
href="{{ cipherDocs.get(r.id) }}"
target="_blank"
rel="noreferrer"
*ngIf="cipherDocs.has(r.id)"
>
{{ "instructions" | i18n }}</a
>
</td>
</tr>
</ng-template>
</tbody></bit-table
>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<a
bitBadge
href="{{ cipherDocs.get(row.id) }}"
target="_blank"
rel="noreferrer"
*ngIf="cipherDocs.has(row.id)"
>
{{ "instructions" | i18n }}</a
>
</td>
</ng-template>
</bit-table-scroll>
</ng-container>
</div>
</bit-container>

View File

@@ -32,68 +32,63 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
<ng-container header *ngIf="!isAdminConsoleActive">
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</tr>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</app-org-badge>
</td>
</tr>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
</ng-template>
</bit-table>
</bit-table-scroll>
</ng-container>
</div>
</bit-container>

View File

@@ -2,8 +2,10 @@
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
<div class="tw-w-full">
<a bitButton routerLink="./" *ngIf="!homepage">
{{ "backToReports" | i18n }}
</a>
@if (!homepage) {
<a bitButton routerLink="./">
{{ "backToReports" | i18n }}
</a>
}
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { Component, OnDestroy } from "@angular/core";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { Subscription } from "rxjs";
import { filter } from "rxjs/operators";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -10,20 +10,20 @@ import { filter } from "rxjs/operators";
templateUrl: "reports-layout.component.html",
standalone: false,
})
export class ReportsLayoutComponent implements OnDestroy {
export class ReportsLayoutComponent {
homepage = true;
subscription: Subscription;
constructor(router: Router) {
this.subscription = router.events
.pipe(filter((event) => event instanceof NavigationEnd))
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
const reportsHomeRoute = "/reports";
this.homepage = router.url === reportsHomeRoute;
router.events
.pipe(
takeUntilDestroyed(),
filter((event) => event instanceof NavigationEnd),
)
.subscribe((event) => {
this.homepage = (event as NavigationEnd).url == "/reports";
this.homepage = (event as NavigationEnd).url == reportsHomeRoute;
});
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}

View File

@@ -86,7 +86,7 @@
@if (showActionButtons) {
<div class="tw-ml-auto">
@if (userCanArchive$ | async) {
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) {
@if (isCipherArchived) {
<button
type="button"

View File

@@ -249,6 +249,15 @@ describe("VaultItemDialogComponent", () => {
});
describe("archive button", () => {
it("should not show archive button in admin console", () => {
(component as any).userCanArchive$ = of(true);
component.setTestCipher({ canBeArchived: true });
component.setTestParams({ mode: "form", isAdminConsoleAction: true });
fixture.detectChanges();
const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']"));
expect(archiveButton).toBeFalsy();
});
it("should show archive button when the user can archive the item and the item can be archived", () => {
component.setTestCipher({ canBeArchived: true });
(component as any).userCanArchive$ = of(true);

View File

@@ -171,37 +171,47 @@
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
@if (!viewingOrgVault) {
<button bitMenuItem type="button" *ngIf="showFavorite" (click)="toggleFavorite()">
@if (showFavorite) {
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
}
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
<button
bitMenuItem
*ngIf="showAssignToCollections"
type="button"
(click)="assignToCollections()"
>
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
@if (!isDeleted && canEditCipher) {
<button bitMenuItem type="button" (click)="editCipher()">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
}
@if (showAttachments) {
<button bitMenuItem type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
}
@if (showClone) {
<button bitMenuItem type="button" (click)="clone()">
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
}
@if (showAssignToCollections) {
<button
bitMenuItem
*ngIf="showAssignToCollections"
type="button"
(click)="assignToCollections()"
>
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
}
@if (showEventLogs) {
<button bitMenuItem type="button" (click)="events()">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
}
@if (showArchiveButton) {
@if (userCanArchive) {
<button bitMenuItem (click)="archive()" type="button">

View File

@@ -161,7 +161,9 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return false;
}
return CipherViewLikeUtils.isArchived(this.cipher);
return (
CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher)
);
}
protected get clickAction() {
@@ -191,7 +193,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// Do not show attachments button if:
// item is archived AND user is not premium user
protected get showAttachments() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
if ((CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) || this.isDeleted) {
return false;
}
return this.canEditCipher || this.hasAttachments;
@@ -387,7 +389,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
protected get showFavorite() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
if (
(!this.viewingOrgVault &&
CipherViewLikeUtils.isArchived(this.cipher) &&
!this.userCanArchive) ||
CipherViewLikeUtils.isDeleted(this.cipher)
) {
return false;
}
return true;

View File

@@ -84,20 +84,19 @@
{{ "assignToCollections" | i18n }}
</button>
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
@if (bulkArchiveAllowed) {
<button type="button" bitMenuItem (click)="bulkArchive()">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
<button
*ngIf="bulkUnarchiveAllowed"
type="button"
bitMenuItem
(click)="bulkUnarchive()"
>
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
@if (bulkUnarchiveAllowed) {
<button type="button" bitMenuItem (click)="bulkUnarchive()">
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
}
<button
*ngIf="canRestoreSelected$ | async"

View File

@@ -277,7 +277,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
get bulkArchiveAllowed() {
const hasCollectionsSelected = this.selection.selected.some((item) => item.collection);
if (this.selection.selected.length === 0 || !this.userCanArchive || hasCollectionsSelected) {
if (
this.selection.selected.length === 0 ||
!this.userCanArchive ||
hasCollectionsSelected ||
this.showBulkTrashOptions
) {
return false;
}
@@ -291,7 +296,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
get bulkUnarchiveAllowed() {
if (this.selection.selected.length === 0) {
if (this.selection.selected.length === 0 || this.showBulkTrashOptions) {
return false;
}

View File

@@ -1271,6 +1271,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
restore = async (c: C): Promise<boolean> => {
let toastMessage;
if (!CipherViewLikeUtils.isDeleted(c)) {
return;
}
@@ -1284,13 +1285,19 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return;
}
if (CipherViewLikeUtils.isArchived(c)) {
toastMessage = this.i18nService.t("archivedItemRestored");
} else {
toastMessage = this.i18nService.t("restoredItem");
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
message: toastMessage,
});
this.refresh();
} catch (e) {
@@ -1299,11 +1306,18 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
};
async bulkRestore(ciphers: C[]) {
let toastMessage;
if (ciphers.some((c) => !c.edit)) {
this.showMissingPermissionsError();
return;
}
if (ciphers.some((c) => !CipherViewLikeUtils.isArchived(c))) {
toastMessage = this.i18nService.t("restoredItems");
} else {
toastMessage = this.i18nService.t("archivedItemsRestored");
}
if (!(await this.repromptCipher(ciphers))) {
return;
}
@@ -1323,7 +1337,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItems"),
message: toastMessage,
});
this.refresh();
}

View File

@@ -5418,6 +5418,12 @@
"restoreSelected": {
"message": "Restore selected"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"archivedItemsRestored": {
"message": "Archived items restored"
},
"restoredItem": {
"message": "Item restored"
},

View File

@@ -0,0 +1,225 @@
<app-header>
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
</bit-search>
<button type="button" bitButton buttonType="primary" (click)="invite()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
</app-header>
<div class="tw-mb-4 tw-flex tw-flex-col tw-space-y-4">
<bit-toggle-group
[selected]="status"
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="null">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">
{{ allCount }}
</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">
{{ invitedCount }}
</span>
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedCount">
{{ acceptedCount }}
</span>
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
>
</i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">
{{ "all" | i18n }}
</label>
</th>
<th bitCell bitSortable="email" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="type">{{ "role" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<button
type="button"
bitMenuItem
(click)="bulkReinvite()"
*ngIf="showBulkReinviteUsers"
>
<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)="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 let-rows$>
<tr
bitRow
*cdkVirtualFor="let user of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="dataSource.checkUser(user)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(user).checked" />
</td>
<td bitCell (click)="edit(user)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="user | userName"
[id]="user.userId"
[color]="user.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<button type="button" bitLink>
{{ user.name ?? user.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="user.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="user.name">
{{ user.email }}
</div>
</div>
</div>
</td>
<td bitCell class="tw-text-muted">
<span *ngIf="user.type === userType.ProviderAdmin">{{ "providerAdmin" | i18n }}</span>
<span *ngIf="user.type === userType.ServiceUser">{{ "serviceUser" | i18n }}</span>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(user)"
*ngIf="user.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(user)"
*ngIf="user.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(user)"
*ngIf="user.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(user)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,338 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
import { first, map } from "rxjs/operators";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request";
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ProviderId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
peopleFilter,
PeopleTableDataSource,
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import {
AddEditMemberDialogComponent,
AddEditMemberDialogParams,
AddEditMemberDialogResultType,
} from "./dialogs/add-edit-member-dialog.component";
import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component";
import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component";
type ProviderUser = ProviderUserUserDetailsResponse;
class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
protected statusType = ProviderUserStatusType;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "deprecated_members.component.html",
standalone: false,
})
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
accessEvents = false;
dataSource: MembersTableDataSource;
loading = true;
providerId: string;
rowHeight = 70;
rowHeightClass = `tw-h-[70px]`;
status: ProviderUserStatusType = null;
userStatusType = ProviderUserStatusType;
userType = ProviderUserType;
constructor(
apiService: ApiService,
keyService: KeyService,
dialogService: DialogService,
i18nService: I18nService,
logService: LogService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
toastService: ToastService,
userNamePipe: UserNamePipe,
validationService: ValidationService,
private encryptService: EncryptService,
private activatedRoute: ActivatedRoute,
private providerService: ProviderService,
private router: Router,
private accountService: AccountService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
combineLatest([
this.activatedRoute.parent.params,
this.activatedRoute.queryParams.pipe(first()),
])
.pipe(
switchMap(async ([urlParams, queryParams]) => {
this.searchControl.setValue(queryParams.search);
this.dataSource.filter = peopleFilter(queryParams.search, null);
this.providerId = urlParams.providerId;
const provider = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.providerId, userId)),
),
);
if (!provider || !provider.canManageUsers) {
return await this.router.navigate(["../"], { relativeTo: this.activatedRoute });
}
this.accessEvents = provider.useEvents;
await this.load();
if (queryParams.viewEvents != null) {
const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents);
if (user && user.status === ProviderUserStatusType.Confirmed) {
this.openEventsDialog(user);
}
}
}),
takeUntilDestroyed(),
)
.subscribe();
}
async bulkConfirm(): Promise<void> {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
await this.load();
}
async bulkReinvite(): Promise<void> {
if (this.actionPromise != null) {
return;
}
let users: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
}
const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited);
// Capture the original count BEFORE enforcing the limit
const originalInvitedCount = allInvitedUsers.length;
// When feature flag is enabled, limit invited users and uncheck the excess
let checkedInvitedUsers: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
checkedInvitedUsers = this.dataSource.limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
} else {
checkedInvitedUsers = allInvitedUsers;
}
if (checkedInvitedUsers.length <= 0) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("noSelectedUsersApplicable"),
});
return;
}
try {
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
await this.apiService.postManyProviderUserReinvite(
this.providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
const selectedCount = originalInvitedCount;
const invitedCount = checkedInvitedUsers.length;
if (selectedCount > CloudBulkReinviteLimit) {
const excludedCount = selectedCount - CloudBulkReinviteLimit;
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
"bulkReinviteLimitedSuccessToast",
CloudBulkReinviteLimit.toLocaleString(),
selectedCount.toLocaleString(),
excludedCount.toLocaleString(),
),
});
} else {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()),
});
}
} else {
// Feature flag disabled - show legacy dialog
const request = this.apiService.postManyProviderUserReinvite(
this.providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
const dialogRef = BulkStatusComponent.open(this.dialogService, {
data: {
users: users,
filteredUsers: checkedInvitedUsers,
request,
successfulMessage: this.i18nService.t("bulkReinviteMessage"),
},
});
await lastValueFrom(dialogRef.closed);
}
} catch (error) {
this.validationService.showError(error);
}
}
async invite() {
await this.edit(null);
}
async bulkRemove(): Promise<void> {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: users,
},
});
await lastValueFrom(dialogRef.closed);
await this.load();
}
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<MemberActionResult> {
try {
const providerKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.providerKeys$(userId)),
map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null),
),
);
assertNonNullish(providerKey, "Provider key not found");
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
const request = new ProviderUserConfirmRequest(key.encryptedString);
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
removeUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.deleteProviderUser(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
edit = async (user: ProviderUser | null): Promise<void> => {
const data: AddEditMemberDialogParams = {
providerId: this.providerId,
user,
};
const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, {
data,
});
const result = await lastValueFrom(dialogRef.closed);
switch (result) {
case AddEditMemberDialogResultType.Saved:
case AddEditMemberDialogResultType.Deleted:
await this.load();
break;
}
};
openEventsDialog = (user: ProviderUser): DialogRef<void> =>
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
providerId: this.providerId,
entityId: user.id,
showUser: false,
entity: "user",
},
});
getUsers = (): Promise<ListResponse<ProviderUser>> =>
this.apiService.getProviderUsers(this.providerId);
reinviteUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.postProviderUserReinvite(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
}

View File

@@ -3,6 +3,7 @@
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request";
@@ -15,14 +16,11 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
export type AddEditMemberDialogParams = {
providerId: string;
user?: {
id: string;
name: string;
type: ProviderUserType;
};
user?: ProviderUser;
};
// FIXME: update to use a const object instead of a typescript enum
@@ -59,6 +57,7 @@ export class AddEditMemberDialogComponent {
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private userNamePipe: UserNamePipe,
) {
this.editing = this.loading = this.dialogParams.user != null;
if (this.editing) {
@@ -78,8 +77,10 @@ export class AddEditMemberDialogComponent {
return;
}
const userName = this.userNamePipe.transform(this.dialogParams.user);
const confirmed = await this.dialogService.openSimpleDialog({
title: this.dialogParams.user.name,
title: userName,
content: { key: "removeUserConfirmation" },
type: "warning",
});
@@ -96,7 +97,7 @@ export class AddEditMemberDialogComponent {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("removedUserId", this.dialogParams.user.name),
message: this.i18nService.t("removedUserId", userName),
});
this.dialogRef.close(AddEditMemberDialogResultType.Deleted);
@@ -118,13 +119,12 @@ export class AddEditMemberDialogComponent {
await this.apiService.postProviderUserInvite(this.dialogParams.providerId, request);
}
const userName = this.editing ? this.userNamePipe.transform(this.dialogParams.user) : undefined;
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.editing ? "editedUserId" : "invitedUsers",
this.dialogParams.user?.name,
),
message: this.i18nService.t(this.editing ? "editedUserId" : "invitedUsers", userName),
});
this.dialogRef.close(AddEditMemberDialogResultType.Saved);

View File

@@ -36,6 +36,7 @@ type BulkConfirmDialogParams = {
@Component({
templateUrl:
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html",
selector: "provider-bulk-comfirm-dialog",
standalone: false,
})
export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent {

View File

@@ -21,6 +21,7 @@ type BulkRemoveDialogParams = {
@Component({
templateUrl:
"../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html",
selector: "provider-bulk-remove-dialog",
standalone: false,
})
export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent {

View File

@@ -1,7 +1,13 @@
@let providerId = providerId$ | async;
@let bulkMenuOptions = bulkMenuOptions$ | async;
@let showConfirmBanner = showConfirmBanner$ | async;
@let dataSource = this.dataSource();
@let isProcessing = this.isProcessing();
<app-header>
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
</bit-search>
<button type="button" bitButton buttonType="primary" (click)="invite()">
<button type="button" bitButton buttonType="primary" (click)="edit(providerId)">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "inviteMember" | i18n }}
</button>
@@ -13,28 +19,28 @@
(selectedChange)="statusToggle.next($event)"
[attr.aria-label]="'memberStatusFilter' | i18n"
>
<bit-toggle [value]="null">
<bit-toggle [value]="undefined">
{{ "all" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.activeUserCount as allCount">
{{ allCount }}
</span>
@if (dataSource.activeUserCount; as allCount) {
<span bitBadge variant="info">{{ allCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Invited">
{{ "invited" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.invitedUserCount as invitedCount">
{{ invitedCount }}
</span>
@if (dataSource.invitedUserCount; as invitedCount) {
<span bitBadge variant="info">{{ invitedCount }}</span>
}
</bit-toggle>
<bit-toggle [value]="userStatusType.Accepted">
{{ "needsConfirmation" | i18n }}
<span bitBadge variant="info" *ngIf="dataSource.acceptedUserCount as acceptedCount">
{{ acceptedCount }}
</span>
@if (dataSource.acceptedUserCount; as acceptedCount) {
<span bitBadge variant="info">{{ acceptedCount }}</span>
}
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="!firstLoaded">
@if (!firstLoaded()) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
@@ -42,19 +48,16 @@
>
</i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<bit-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
icon="bwi-check-circle"
*ngIf="showConfirmUsers"
>
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
} @else {
@if (!dataSource.filteredData?.length) {
<p>{{ "noMembersInList" | i18n }}</p>
}
@if (dataSource.filteredData?.length) {
@if (showConfirmBanner) {
<bit-callout type="info" title="{{ 'confirmUsers' | i18n }}" icon="bwi-check-circle">
{{ "providerUsersNeedConfirmed" | i18n }}
</bit-callout>
}
<cdk-virtual-scroll-viewport bitScrollLayout [itemSize]="rowHeight" class="tw-pb-8">
<bit-table [dataSource]="dataSource">
<ng-container header>
@@ -82,27 +85,33 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
@if (bulkMenuOptions.showBulkReinviteUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkReinvite(providerId)"
>
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
}
@if (bulkMenuOptions.showBulkConfirmUsers) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : bulkConfirm(providerId)"
>
<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)="bulkReinvite()"
*ngIf="showBulkReinviteUsers"
(click)="isProcessing ? null : bulkRemove(providerId)"
>
<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)="bulkRemove()">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i>
{{ "remove" | i18n }}
@@ -122,7 +131,7 @@
<td bitCell (click)="dataSource.checkUser(user)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(user).checked" />
</td>
<td bitCell (click)="edit(user)" class="tw-cursor-pointer">
<td bitCell (click)="edit(providerId, user)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
@@ -132,44 +141,41 @@
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<div class="tw-flex tw-flex-row tw-gap-2">
<button type="button" bitLink>
{{ user.name ?? user.email }}
</button>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Invited"
>
{{ "invited" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="warning"
*ngIf="user.status === userStatusType.Accepted"
>
{{ "needsConfirmation" | i18n }}
</span>
<span
bitBadge
class="tw-text-xs"
variant="secondary"
*ngIf="user.status === userStatusType.Revoked"
>
{{ "revoked" | i18n }}
</span>
</div>
<div class="tw-text-sm tw-text-muted" *ngIf="user.name">
{{ user.email }}
@if (user.status === userStatusType.Invited) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "invited" | i18n }}
</span>
}
@if (user.status === userStatusType.Accepted) {
<span bitBadge class="tw-text-xs" variant="warning">
{{ "needsConfirmation" | i18n }}
</span>
}
@if (user.status === userStatusType.Revoked) {
<span bitBadge class="tw-text-xs" variant="secondary">
{{ "revoked" | i18n }}
</span>
}
</div>
@if (user.name) {
<div class="tw-text-sm tw-text-muted">
{{ user.email }}
</div>
}
</div>
</div>
</td>
<td bitCell class="tw-text-muted">
<span *ngIf="user.type === userType.ProviderAdmin">{{ "providerAdmin" | i18n }}</span>
<span *ngIf="user.type === userType.ServiceUser">{{ "serviceUser" | i18n }}</span>
@if (user.type === userType.ProviderAdmin) {
<span>{{ "providerAdmin" | i18n }}</span>
}
@if (user.type === userType.ServiceUser) {
<span>{{ "serviceUser" | i18n }}</span>
}
</td>
<td bitCell>
<button
@@ -180,36 +186,43 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
@if (user.status === userStatusType.Invited) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : reinvite(user, providerId)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
}
@if (user.status === userStatusType.Accepted) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : confirm(user, providerId)"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</span>
</button>
}
@if (accessEvents && user.status === userStatusType.Confirmed) {
<button
type="button"
bitMenuItem
(click)="isProcessing ? null : openEventsDialog(user, providerId)"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
}
<button
type="button"
bitMenuItem
(click)="reinvite(user)"
*ngIf="user.status === userStatusType.Invited"
(click)="isProcessing ? null : remove(user, providerId)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(user)"
*ngIf="user.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirm" | i18n }}
</span>
</button>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(user)"
*ngIf="user.status === userStatusType.Confirmed"
>
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(user)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
@@ -221,5 +234,5 @@
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
}
}

View File

@@ -1,55 +1,59 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { Component, inject, signal, WritableSignal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs";
import { FormControl } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import {
BehaviorSubject,
combineLatest,
debounceTime,
firstValueFrom,
lastValueFrom,
Observable,
switchMap,
} from "rxjs";
import { first, map } from "rxjs/operators";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request";
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ProviderId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component";
import { LogService } from "@bitwarden/logging";
import {
CloudBulkReinviteLimit,
MaxCheckedCount,
peopleFilter,
PeopleTableDataSource,
ProviderUser,
ProvidersTableDataSource,
showConfirmBanner,
} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component";
import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component";
import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import {
AddEditMemberDialogComponent,
AddEditMemberDialogParams,
AddEditMemberDialogResultType,
} from "./dialogs/add-edit-member-dialog.component";
import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component";
import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component";
import {
MemberActionResult,
ProviderActionsService,
} from "./services/provider-actions/provider-actions.service";
type ProviderUser = ProviderUserUserDetailsResponse;
class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
protected statusType = ProviderUserStatusType;
interface BulkProviderFlags {
showBulkConfirmUsers: boolean;
showBulkReinviteUsers: boolean;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -58,77 +62,80 @@ class MembersTableDataSource extends PeopleTableDataSource<ProviderUser> {
templateUrl: "members.component.html",
standalone: false,
})
export class MembersComponent extends BaseMembersComponent<ProviderUser> {
accessEvents = false;
dataSource: MembersTableDataSource;
loading = true;
providerId: string;
rowHeight = 70;
rowHeightClass = `tw-h-[70px]`;
status: ProviderUserStatusType = null;
export class vNextMembersComponent {
protected apiService = inject(ApiService);
protected dialogService = inject(DialogService);
protected i18nService = inject(I18nService);
protected userNamePipe = inject(UserNamePipe);
protected validationService = inject(ValidationService);
protected toastService = inject(ToastService);
private activatedRoute = inject(ActivatedRoute);
private providerService = inject(ProviderService);
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private environmentService = inject(EnvironmentService);
private providerActionsService = inject(ProviderActionsService);
private memberActionsService = inject(MemberActionsService);
private logService = inject(LogService);
userStatusType = ProviderUserStatusType;
userType = ProviderUserType;
protected accessEvents = false;
constructor(
apiService: ApiService,
keyService: KeyService,
dialogService: DialogService,
i18nService: I18nService,
logService: LogService,
organizationManagementPreferencesService: OrganizationManagementPreferencesService,
toastService: ToastService,
userNamePipe: UserNamePipe,
validationService: ValidationService,
private encryptService: EncryptService,
private activatedRoute: ActivatedRoute,
private providerService: ProviderService,
private router: Router,
private accountService: AccountService,
private configService: ConfigService,
private environmentService: EnvironmentService,
) {
super(
apiService,
i18nService,
keyService,
validationService,
logService,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
toastService,
);
protected providerId$: Observable<ProviderId>;
protected provider$: Observable<Provider | undefined>;
this.dataSource = new MembersTableDataSource(this.configService, this.environmentService);
protected rowHeight = 70;
protected rowHeightClass = `tw-h-[70px]`;
protected status: ProviderUserStatusType | undefined;
combineLatest([
this.activatedRoute.parent.params,
this.activatedRoute.queryParams.pipe(first()),
])
protected userStatusType = ProviderUserStatusType;
protected userType = ProviderUserType;
protected searchControl = new FormControl("", { nonNullable: true });
protected statusToggle = new BehaviorSubject<ProviderUserStatusType | undefined>(undefined);
protected readonly dataSource: WritableSignal<ProvidersTableDataSource> = signal(
new ProvidersTableDataSource(this.configService, this.environmentService),
);
protected readonly firstLoaded: WritableSignal<boolean> = signal(false);
protected bulkMenuOptions$ = this.dataSource()
.usersUpdated()
.pipe(map((members) => this.bulkMenuOptions(members)));
protected showConfirmBanner$ = this.dataSource()
.usersUpdated()
.pipe(map(() => showConfirmBanner(this.dataSource())));
protected isProcessing = this.providerActionsService.isProcessing;
constructor() {
// Connect the search input and status toggles to the table dataSource filter
combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle])
.pipe(takeUntilDestroyed())
.subscribe(
([searchText, status]) => (this.dataSource().filter = peopleFilter(searchText, status)),
);
this.providerId$ = this.activatedRoute.params.pipe(map((params) => params.providerId));
this.provider$ = combineLatest([
this.providerId$,
this.accountService.activeAccount$.pipe(getUserId),
]).pipe(switchMap(([providerId, userId]) => this.providerService.get$(providerId, userId)));
combineLatest([this.activatedRoute.queryParams, this.providerId$])
.pipe(
switchMap(async ([urlParams, queryParams]) => {
first(),
switchMap(async ([queryParams, providerId]) => {
this.searchControl.setValue(queryParams.search);
this.dataSource.filter = peopleFilter(queryParams.search, null);
this.dataSource().filter = peopleFilter(queryParams.search, undefined);
this.providerId = urlParams.providerId;
const provider = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.providerService.get$(this.providerId, userId)),
),
);
if (!provider || !provider.canManageUsers) {
return await this.router.navigate(["../"], { relativeTo: this.activatedRoute });
}
this.accessEvents = provider.useEvents;
await this.load();
if (queryParams.viewEvents != null) {
const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents);
const user = this.dataSource().data.find((user) => user.id === queryParams.viewEvents);
if (user && user.status === ProviderUserStatusType.Confirmed) {
this.openEventsDialog(user);
this.openEventsDialog(user, providerId);
}
}
}),
@@ -137,17 +144,19 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
.subscribe();
}
async bulkConfirm(): Promise<void> {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
async load() {
const providerId = await firstValueFrom(this.providerId$);
const response = await this.apiService.getProviderUsers(providerId);
this.dataSource().data = response.data;
this.firstLoaded.set(true);
}
async bulkConfirm(providerId: ProviderId): Promise<void> {
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: users,
providerId: providerId,
users,
},
});
@@ -155,16 +164,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
}
async bulkReinvite(): Promise<void> {
if (this.actionPromise != null) {
return;
}
async bulkReinvite(providerId: ProviderId): Promise<void> {
let users: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
users = this.dataSource.getCheckedUsersInVisibleOrder();
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
users = this.dataSource().getCheckedUsersInVisibleOrder();
} else {
users = this.dataSource.getCheckedUsers();
users = this.dataSource().getCheckedUsers();
}
const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited);
@@ -174,8 +179,8 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
// When feature flag is enabled, limit invited users and uncheck the excess
let checkedInvitedUsers: ProviderUser[];
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
checkedInvitedUsers = this.dataSource.limitAndUncheckExcess(
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
checkedInvitedUsers = this.dataSource().limitAndUncheckExcess(
allInvitedUsers,
CloudBulkReinviteLimit,
);
@@ -194,9 +199,9 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
try {
// When feature flag is enabled, show toast instead of dialog
if (this.dataSource.isIncreasedBulkLimitEnabled()) {
if (this.dataSource().isIncreasedBulkLimitEnabled()) {
await this.apiService.postManyProviderUserReinvite(
this.providerId,
providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
@@ -223,7 +228,7 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
} else {
// Feature flag disabled - show legacy dialog
const request = this.apiService.postManyProviderUserReinvite(
this.providerId,
providerId,
new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)),
);
@@ -242,21 +247,12 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
}
}
async invite() {
await this.edit(null);
}
async bulkRemove(): Promise<void> {
if (this.actionPromise != null) {
return;
}
const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount);
async bulkRemove(providerId: ProviderId): Promise<void> {
const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount);
const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, {
data: {
providerId: this.providerId,
users: users,
providerId: providerId,
users,
},
});
@@ -264,51 +260,58 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
}
async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise<MemberActionResult> {
try {
const providerKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.providerKeys$(userId)),
map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null),
),
);
assertNonNullish(providerKey, "Provider key not found");
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
const request = new ProviderUserConfirmRequest();
request.key = key.encryptedString;
await this.apiService.postProviderUserConfirm(this.providerId, user.id, request);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
private async removeUserConfirmationDialog(user: ProviderUser) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
content: { key: "removeUserConfirmation" },
type: "warning",
});
}
removeUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.deleteProviderUser(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
async remove(user: ProviderUser, providerId: ProviderId) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
};
edit = async (user: ProviderUser | null): Promise<void> => {
const data: AddEditMemberDialogParams = {
providerId: this.providerId,
const sideEffect = () => this.dataSource().removeUser(user);
const result = await this.providerActionsService.deleteProviderUser(providerId, user);
await this.handleMemberActionResult(result, "success", user, sideEffect);
}
async reinvite(user: ProviderUser, providerId: ProviderId) {
const result = await this.providerActionsService.reinviteProvider(providerId, user);
await this.handleMemberActionResult(result, "success", user);
}
async confirm(user: ProviderUser, providerId: ProviderId) {
const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user);
if (publicKeyResult == null) {
this.logService.warning("Public key not found");
return;
}
const result = await this.providerActionsService.confirmProvider(
user,
providerId,
publicKeyResult,
);
const sideEffect = () => {
user.status = this.userStatusType.Confirmed;
this.dataSource().replaceUser(user);
};
if (user != null) {
data.user = {
id: user.id,
name: this.userNamePipe.transform(user),
type: user.type,
};
}
await this.handleMemberActionResult(result, "success", user, sideEffect);
}
async edit(providerId: ProviderId, user?: ProviderUser): Promise<void> {
const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, {
data,
data: {
providerId,
user,
},
});
const result = await lastValueFrom(dialogRef.closed);
@@ -319,28 +322,54 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
await this.load();
break;
}
};
}
openEventsDialog = (user: ProviderUser): DialogRef<void> =>
openEntityEventsDialog(this.dialogService, {
openEventsDialog(user: ProviderUser, providerId: ProviderId): DialogRef<void> {
return openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
providerId: this.providerId,
providerId: providerId,
entityId: user.id,
showUser: false,
entity: "user",
},
});
}
getUsers = (): Promise<ListResponse<ProviderUser>> =>
this.apiService.getProviderUsers(this.providerId);
private bulkMenuOptions(providerMembers: ProviderUser[]): BulkProviderFlags {
const result: BulkProviderFlags = {
showBulkConfirmUsers: providerMembers.every(
(m) => m.status == ProviderUserStatusType.Accepted,
),
showBulkReinviteUsers: providerMembers.every(
(m) => m.status == ProviderUserStatusType.Invited,
),
};
reinviteUser = async (id: string): Promise<MemberActionResult> => {
try {
await this.apiService.postProviderUserReinvite(this.providerId, id);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
return result;
}
async handleMemberActionResult(
result: MemberActionResult,
successKey: string,
user: ProviderUser,
sideEffect?: () => void | Promise<void>,
) {
if (result.error != null) {
this.validationService.showError(result.error);
this.logService.error(result.error);
return;
}
};
if (result.success) {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(successKey, this.userNamePipe.transform(user)),
});
if (sideEffect) {
await sideEffect();
}
}
}
}

View File

@@ -0,0 +1,133 @@
import { TestBed } from "@angular/core/testing";
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ProviderId, UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
import { ProviderActionsService } from "./provider-actions.service";
describe("ProviderActionsService", () => {
let service: ProviderActionsService;
let apiService: MockProxy<ApiService>;
let keyService: MockProxy<KeyService>;
let accountService: MockProxy<AccountService>;
let encryptService: MockProxy<EncryptService>;
const providerId = newGuid() as ProviderId;
const userId = newGuid();
const userIdToManage = newGuid();
let mockProviderUser: ProviderUser;
beforeEach(() => {
apiService = mock<ApiService>();
keyService = mock<KeyService>();
accountService = mock<AccountService>();
encryptService = mock<EncryptService>();
mockProviderUser = {
id: userIdToManage,
userId: userIdToManage,
email: "test@example.com",
name: "Test User",
} as ProviderUser;
TestBed.configureTestingModule({
providers: [
ProviderActionsService,
{ provide: ApiService, useValue: apiService },
{ provide: KeyService, useValue: keyService },
{ provide: AccountService, useValue: accountService },
{ provide: EncryptService, useValue: encryptService },
],
});
service = TestBed.inject(ProviderActionsService);
});
describe("deleteProviderUser", () => {
it("should return success when deletion succeeds", async () => {
apiService.deleteProviderUser.mockResolvedValue(undefined);
const result = await service.deleteProviderUser(providerId, mockProviderUser);
expect(result.success).toBe(true);
});
it("should return error when deletion fails", async () => {
apiService.deleteProviderUser.mockRejectedValue(new Error("Delete failed"));
const result = await service.deleteProviderUser(providerId, mockProviderUser);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe("reinviteProvider", () => {
it("should return success when reinvite succeeds", async () => {
apiService.postProviderUserReinvite.mockResolvedValue(undefined);
const result = await service.reinviteProvider(providerId, mockProviderUser);
expect(result.success).toBe(true);
});
it("should return error when reinvite fails", async () => {
apiService.postProviderUserReinvite.mockRejectedValue(new Error("Reinvite failed"));
const result = await service.reinviteProvider(providerId, mockProviderUser);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe("confirmProvider", () => {
const publicKey = new Uint8Array([1, 2, 3, 4, 5]);
it("should return success when confirmation succeeds", async () => {
const mockAccount: Account = {
id: userId as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
creationDate: new Date(),
};
const activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
accountService.activeAccount$ = activeAccount$;
keyService.providerKeys$.mockReturnValue(of({ [providerId]: { key: "mock" } as any }));
encryptService.encapsulateKeyUnsigned.mockResolvedValue(new EncString("encrypted"));
apiService.postProviderUserConfirm.mockResolvedValue(undefined);
const result = await service.confirmProvider(mockProviderUser, providerId, publicKey);
expect(result.success).toBe(true);
});
it("should return error when confirmation fails", async () => {
const mockAccount: Account = {
id: userId as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
creationDate: new Date(),
};
const activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
accountService.activeAccount$ = activeAccount$;
keyService.providerKeys$.mockReturnValue(of({}));
const result = await service.confirmProvider(mockProviderUser, providerId, publicKey);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});

View File

@@ -0,0 +1,91 @@
import { inject, Injectable, signal } from "@angular/core";
import { firstValueFrom, switchMap, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ProviderId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source";
export interface MemberActionResult {
success: boolean;
error?: string;
}
@Injectable()
export class ProviderActionsService {
private apiService = inject(ApiService);
private keyService = inject(KeyService);
private accountService = inject(AccountService);
private encryptService = inject(EncryptService);
readonly isProcessing = signal(false);
private startProcessing(): void {
this.isProcessing.set(true);
}
private endProcessing(): void {
this.isProcessing.set(false);
}
async deleteProviderUser(
providerId: ProviderId,
user: ProviderUser,
): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.apiService.deleteProviderUser(providerId, user.id);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async reinviteProvider(providerId: ProviderId, user: ProviderUser): Promise<MemberActionResult> {
this.startProcessing();
try {
await this.apiService.postProviderUserReinvite(providerId, user.id);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
async confirmProvider(
user: ProviderUser,
providerId: ProviderId,
publicKey: Uint8Array,
): Promise<MemberActionResult> {
this.startProcessing();
try {
const providerKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.providerKeys$(userId)),
map((providerKeys) => providerKeys?.[providerId] ?? null),
),
);
assertNonNullish(providerKey, "Provider key not found");
const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey);
assertNonNullish(key.encryptedString, "No key was provided");
const request = new ProviderUserConfirmRequest(key.encryptedString);
await this.apiService.postProviderUserConfirm(providerId, user.id, request);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message ?? String(error) };
} finally {
this.endProcessing();
}
}
}

View File

@@ -2,7 +2,9 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { authGuard } from "@bitwarden/angular/auth/guards";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnonLayoutWrapperComponent } from "@bitwarden/components";
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
@@ -15,8 +17,9 @@ import { ProviderSubscriptionComponent } from "../../billing/providers/subscript
import { ManageClientsComponent } from "./clients/manage-clients.component";
import { providerPermissionsGuard } from "./guards/provider-permissions.guard";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { MembersComponent } from "./manage/deprecated_members.component";
import { EventsComponent } from "./manage/events.component";
import { MembersComponent } from "./manage/members.component";
import { vNextMembersComponent } from "./manage/members.component";
import { ProvidersLayoutComponent } from "./providers-layout.component";
import { ProvidersComponent } from "./providers.component";
import { AccountComponent } from "./settings/account.component";
@@ -92,16 +95,20 @@ const routes: Routes = [
pathMatch: "full",
redirectTo: "members",
},
{
path: "members",
component: MembersComponent,
canActivate: [
providerPermissionsGuard((provider: Provider) => provider.canManageUsers),
],
data: {
titleId: "members",
...featureFlaggedRoute({
defaultComponent: MembersComponent,
flaggedComponent: vNextMembersComponent,
featureFlag: FeatureFlag.MembersComponentRefactor,
routeOptions: {
path: "members",
canActivate: [
providerPermissionsGuard((provider: Provider) => provider.canManageUsers),
],
data: {
titleId: "members",
},
},
},
}),
{
path: "events",
component: EventsComponent,

View File

@@ -5,6 +5,7 @@ import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components";
import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import {
@@ -26,11 +27,13 @@ import { CreateClientDialogComponent } from "./clients/create-client-dialog.comp
import { ManageClientNameDialogComponent } from "./clients/manage-client-name-dialog.component";
import { ManageClientSubscriptionDialogComponent } from "./clients/manage-client-subscription-dialog.component";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { MembersComponent } from "./manage/deprecated_members.component";
import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component";
import { BulkConfirmDialogComponent } from "./manage/dialogs/bulk-confirm-dialog.component";
import { BulkRemoveDialogComponent } from "./manage/dialogs/bulk-remove-dialog.component";
import { EventsComponent } from "./manage/events.component";
import { MembersComponent } from "./manage/members.component";
import { vNextMembersComponent } from "./manage/members.component";
import { ProviderActionsService } from "./manage/services/provider-actions/provider-actions.service";
import { ProvidersLayoutComponent } from "./providers-layout.component";
import { ProvidersRoutingModule } from "./providers-routing.module";
import { ProvidersComponent } from "./providers.component";
@@ -64,6 +67,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
BulkConfirmDialogComponent,
BulkRemoveDialogComponent,
EventsComponent,
vNextMembersComponent,
MembersComponent,
SetupComponent,
SetupProviderComponent,
@@ -81,6 +85,6 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
VerifyRecoverDeleteProviderComponent,
SetupBusinessUnitComponent,
],
providers: [WebProviderService],
providers: [WebProviderService, ProviderActionsService, MemberActionsService],
})
export class ProvidersModule {}

View File

@@ -121,13 +121,13 @@ export class CollectionAdminView extends CollectionView {
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
view.name = "[error: cannot decrypt]";
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
throw e;
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;

View File

@@ -126,7 +126,14 @@ export class CollectionView implements View, ITreeNodeObject {
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
view.name = await encryptService.decryptString(collection.name, key);
try {
view.name = await encryptService.decryptString(collection.name, key);
} catch (e) {
view.name = "[error: cannot decrypt]";
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
}
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ProviderUserConfirmRequest {
key: string;
protected key: string;
constructor(key: string) {
this.key = key;
}
}

View File

@@ -14,6 +14,7 @@ export enum FeatureFlag {
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -22,6 +23,7 @@ export enum FeatureFlag {
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -99,11 +101,13 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,

View File

@@ -2,7 +2,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -14,7 +13,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
@@ -42,7 +40,6 @@
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
linkType="primary"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
@@ -50,7 +47,7 @@
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
@@ -61,7 +58,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -73,7 +69,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>

View File

@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
import { AriaDisableDirective } from "../a11y";
import { ariaDisableElement } from "../utils";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
export const LinkTypes = [
"primary",
"secondary",
"contrast",
"light",
"default",
"subtle",
"success",
"warning",
"danger",
] as const;
export type LinkType = (typeof LinkTypes)[number];
const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-600",
"hover:!tw-text-primary-700",
"focus-visible:before:tw-ring-primary-600",
],
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"tw-text-fg-contrast",
"hover:tw-text-fg-contrast",
"focus-visible:before:tw-ring-fg-contrast",
],
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
};
const commonStyles = [
@@ -32,16 +45,18 @@ const commonStyles = [
"tw-rounded",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
@@ -63,14 +78,14 @@ const commonStyles = [
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
"aria-disabled:!tw-text-secondary-300",
"aria-disabled:hover:!tw-text-secondary-300",
"aria-disabled:!tw-text-fg-disabled",
"aria-disabled:hover:!tw-text-fg-disabled",
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("primary");
readonly linkType = input<LinkType>("default");
}
/**

View File

@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
You can use one of the following variants by providing it as the `linkType` input:
- `primary` - most common, uses brand color
- `secondary` - matches the main text color
- @deprecated `primary` => use `default` instead
- @deprecated `secondary` => use `subtle` instead
- `default` - most common, uses brand color
- `subtle` - matches the main text color
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
- `light` - always a light color, even in dark mode
- `warning` - used in association with warning callouts/banners
- `success` - used in association with success callouts/banners
- `danger` - used in association with danger callouts/banners
## Sizes

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -14,7 +14,7 @@ export default {
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
options: LinkTypes.map((type) => type),
control: { type: "radio" },
},
},
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
`,
}),
args: {
linkType: "primary",
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const AllVariations: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const Buttons: Story = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
@@ -100,9 +205,17 @@ export const Buttons: Story = {
export const Anchors: StoryObj<AnchorLinkDirective> = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
@@ -138,18 +251,15 @@ export const Inline: Story = {
</span>
`,
}),
args: {
linkType: "primary",
},
};
export const Disabled: Story = {
export const Inactive: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
</div>
`,

View File

@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
A random password
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
<div class="tw-my-6">
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
<a bitLink linkType="primary" href="#">This is a link</a>
<a bitLink href="#">This is a link</a>
<p bitTypography="body1" class="tw-inline">
&nbsp;and this is a link button popover trigger:&nbsp;
</p>
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -1,17 +1,8 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
} from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
ButtonModule,
@@ -57,8 +48,6 @@ export class SendListComponent {
protected readonly noResultsIcon = NoResults;
protected readonly sendListState = SendListState;
private i18nService = inject(I18nService);
readonly sends = input.required<SendView[]>();
readonly loading = input<boolean>(false);
readonly disableSend = input<boolean>(false);
@@ -70,7 +59,7 @@ export class SendListComponent {
);
protected readonly noSearchResults = computed(
() => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0),
() => this.showSearchBar() && this.sends().length === 0,
);
// Reusable data source instance - updated reactively when sends change