1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

Merge branch 'main' into autofill/pm-5189-fix-issues-present-with-inline-menu-rendering-in-iframes

This commit is contained in:
Cesar Gonzalez
2024-06-13 08:08:41 -05:00
committed by GitHub
31 changed files with 1466 additions and 796 deletions

View File

@@ -3323,16 +3323,6 @@
"clearFiltersOrTryAnother": {
"message": "Clear filters or try another search term"
},
"copyInfoLabel": {
"message": "Copy info, $ITEMNAME$",
"description": "Aria label for a button that opens a menu with options to copy information from an item.",
"placeholders": {
"itemname": {
"content": "$1",
"example": "Secret Item"
}
}
},
"copyInfoTitle": {
"message": "Copy info - $ITEMNAME$",
"description": "Title for a button that opens a menu with options to copy information from an item.",
@@ -3343,16 +3333,6 @@
}
}
},
"copyNoteLabel": {
"message": "Copy Note, $ITEMNAME$",
"description": "Aria label for a button copies a note to the clipboard.",
"placeholders": {
"itemname": {
"content": "$1",
"example": "Secret Note Item"
}
}
},
"copyNoteTitle": {
"message": "Copy Note - $ITEMNAME$",
"description": "Title for a button copies a note to the clipboard.",
@@ -3393,6 +3373,19 @@
}
}
},
"autofillTitle": {
"message": "Auto-fill - $ITEMNAME$",
"description": "Title for a button that auto-fills a login item.",
"placeholders": {
"itemname": {
"content": "$1",
"example": "Secret Item"
}
}
},
"noValuesToCopy": {
"message": "No values to copy"
},
"assignCollections": {
"message": "Assign collections"
},

View File

@@ -3,8 +3,10 @@
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[appA11yTitle]="
hasLoginValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
"
[disabled]="!hasLoginValues"
[bitMenuTriggerFor]="loginOptions"
></button>
<bit-menu #loginOptions>
@@ -25,8 +27,10 @@
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[appA11yTitle]="
hasCardValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
"
[disabled]="!hasCardValues"
[bitMenuTriggerFor]="cardOptions"
></button>
<bit-menu #cardOptions>
@@ -44,8 +48,10 @@
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyInfoLabel' | i18n: cipher.name"
[title]="'copyInfoTitle' | i18n: cipher.name"
[appA11yTitle]="
hasIdentityValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
"
[disabled]="!hasIdentityValues"
[bitMenuTriggerFor]="identityOptions"
></button>
<bit-menu #identityOptions>
@@ -69,8 +75,9 @@
type="button"
bitIconButton="bwi-clone"
size="small"
[attr.aria-label]="'copyNoteLabel' | i18n: cipher.name"
[title]="'copyNoteTitle' | i18n: cipher.name"
[appA11yTitle]="
hasSecureNoteValue ? ('copyNoteTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
"
appCopyField="secureNote"
[cipher]="cipher"
></button>

View File

@@ -25,5 +25,28 @@ export class ItemCopyActionsComponent {
protected CipherType = CipherType;
get hasLoginValues() {
return (
!!this.cipher.login.hasTotp || !!this.cipher.login.password || !!this.cipher.login.username
);
}
get hasCardValues() {
return !!this.cipher.card.code || !!this.cipher.card.number;
}
get hasIdentityValues() {
return (
!!this.cipher.identity.fullAddressForCopy ||
!!this.cipher.identity.email ||
!!this.cipher.identity.username ||
!!this.cipher.identity.phone
);
}
get hasSecureNoteValue() {
return !!this.cipher.notes;
}
constructor() {}
}

View File

@@ -8,7 +8,7 @@
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<ng-container *ngIf="isLogin && !hideLoginOptions">
<ng-container *ngIf="canAutofill && !hideAutofillOptions">
<ng-container *ngIf="autofillAllowed$ | async">
<button type="button" bitMenuItem>
{{ "autofill" | i18n }}

View File

@@ -33,11 +33,11 @@ export class ItemMoreOptionsComponent {
cipher: CipherView;
/**
* Flag to hide the login specific menu options. Used for login items that are
* Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
@Input({ transform: booleanAttribute })
hideLoginOptions: boolean;
hideAutofillOptions: boolean;
protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$;
@@ -55,8 +55,11 @@ export class ItemMoreOptionsComponent {
return this.cipher.edit;
}
get isLogin() {
return this.cipher.type === CipherType.Login;
/**
* Determines if the cipher can be autofilled.
*/
get canAutofill() {
return [CipherType.Login, CipherType.Card, CipherType.Identity].includes(this.cipher.type);
}
get favoriteText() {
@@ -67,7 +70,7 @@ export class ItemMoreOptionsComponent {
* Determines if the login cipher can be launched in a new browser tab.
*/
get canLaunch() {
return this.isLogin && this.cipher.login.canLaunch;
return this.cipher.type === CipherType.Login && this.cipher.login.canLaunch;
}
/**

View File

@@ -25,7 +25,7 @@
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
{{ cipher.name }}
<span data-testid="item-name">{{ cipher.name }}</span>
<i
class="bwi bwi-sm"
*ngIf="cipher.organizationId"
@@ -36,12 +36,20 @@
</a>
<ng-container slot="end">
<bit-item-action *ngIf="showAutofillButton">
<button type="button" bitBadge variant="primary">{{ "autoFill" | i18n }}</button>
<button
type="button"
bitBadge
variant="primary"
[title]="'autofillTitle' | i18n: cipher.name"
[attr.aria-label]="'autofillTitle' | i18n: cipher.name"
>
{{ "autoFill" | i18n }}
</button>
</bit-item-action>
<app-item-copy-actions [cipher]="cipher"></app-item-copy-actions>
<app-item-more-options
[cipher]="cipher"
[hideLoginOptions]="showAutofillButton"
[hideAutofillOptions]="showAutofillButton"
></app-item-more-options>
</ng-container>
</bit-item>

View File

@@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
concatMap,
distinctUntilChanged,
distinctUntilKeyChanged,
from,
@@ -176,7 +177,12 @@ export class VaultPopupItemsService {
* Ciphers are sorted by name.
*/
remainingCiphers$: Observable<PopupCipherView[]> = this.favoriteCiphers$.pipe(
withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$),
concatMap(
(
favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$
) =>
of(favoriteCiphers).pipe(withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$)),
),
map(([favoriteCiphers, ciphers, autoFillCiphers]) =>
ciphers.filter(
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),

View File

@@ -1,6 +1,8 @@
import { program } from "commander";
import { OssServeConfigurator } from "./oss-serve-configurator";
import { registerOssPrograms } from "./register-oss-programs";
import { ServeProgram } from "./serve.program";
import { ServiceContainer } from "./service-container";
async function main() {
@@ -9,6 +11,10 @@ async function main() {
await registerOssPrograms(serviceContainer);
// ServeProgram is registered separately so it can be overridden by bit-cli
const serveConfigurator = new OssServeConfigurator(serviceContainer);
new ServeProgram(serviceContainer, serveConfigurator).register();
program.parse(process.argv);
}

View File

@@ -1,4 +1,3 @@
import * as koaMulter from "@koa/multer";
import * as koaRouter from "@koa/router";
import { OptionValues } from "commander";
import * as koa from "koa";
@@ -7,170 +6,14 @@ import * as koaJson from "koa-json";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ConfirmCommand } from "../admin-console/commands/confirm.command";
import { ShareCommand } from "../admin-console/commands/share.command";
import { LockCommand } from "../auth/commands/lock.command";
import { UnlockCommand } from "../auth/commands/unlock.command";
import { Response } from "../models/response";
import { FileResponse } from "../models/response/file.response";
import { OssServeConfigurator } from "../oss-serve-configurator";
import { ServiceContainer } from "../service-container";
import { GenerateCommand } from "../tools/generate.command";
import {
SendEditCommand,
SendCreateCommand,
SendDeleteCommand,
SendGetCommand,
SendListCommand,
SendRemovePasswordCommand,
} from "../tools/send";
import { CreateCommand } from "../vault/create.command";
import { DeleteCommand } from "../vault/delete.command";
import { SyncCommand } from "../vault/sync.command";
import { EditCommand } from "./edit.command";
import { GetCommand } from "./get.command";
import { ListCommand } from "./list.command";
import { RestoreCommand } from "./restore.command";
import { StatusCommand } from "./status.command";
export class ServeCommand {
private listCommand: ListCommand;
private getCommand: GetCommand;
private createCommand: CreateCommand;
private editCommand: EditCommand;
private generateCommand: GenerateCommand;
private shareCommand: ShareCommand;
private statusCommand: StatusCommand;
private syncCommand: SyncCommand;
private deleteCommand: DeleteCommand;
private confirmCommand: ConfirmCommand;
private restoreCommand: RestoreCommand;
private lockCommand: LockCommand;
private unlockCommand: UnlockCommand;
private sendCreateCommand: SendCreateCommand;
private sendDeleteCommand: SendDeleteCommand;
private sendEditCommand: SendEditCommand;
private sendGetCommand: SendGetCommand;
private sendListCommand: SendListCommand;
private sendRemovePasswordCommand: SendRemovePasswordCommand;
constructor(protected serviceContainer: ServiceContainer) {
this.getCommand = new GetCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.collectionService,
this.serviceContainer.totpService,
this.serviceContainer.auditService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,
this.serviceContainer.eventCollectionService,
this.serviceContainer.billingAccountProfileStateService,
);
this.listCommand = new ListCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.collectionService,
this.serviceContainer.organizationService,
this.serviceContainer.searchService,
this.serviceContainer.organizationUserService,
this.serviceContainer.apiService,
this.serviceContainer.eventCollectionService,
);
this.createCommand = new CreateCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.cryptoService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
this.serviceContainer.organizationService,
);
this.editCommand = new EditCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.cryptoService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
);
this.generateCommand = new GenerateCommand(
this.serviceContainer.passwordGenerationService,
this.serviceContainer.stateService,
);
this.syncCommand = new SyncCommand(this.serviceContainer.syncService);
this.statusCommand = new StatusCommand(
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.accountService,
this.serviceContainer.authService,
);
this.deleteCommand = new DeleteCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.confirmCommand = new ConfirmCommand(
this.serviceContainer.apiService,
this.serviceContainer.cryptoService,
this.serviceContainer.organizationUserService,
);
this.restoreCommand = new RestoreCommand(this.serviceContainer.cipherService);
this.shareCommand = new ShareCommand(this.serviceContainer.cipherService);
this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService);
this.unlockCommand = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.apiService,
this.serviceContainer.logService,
this.serviceContainer.keyConnectorService,
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.kdfConfigService,
);
this.sendCreateCommand = new SendCreateCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.sendApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.sendDeleteCommand = new SendDeleteCommand(
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
);
this.sendGetCommand = new SendGetCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.searchService,
this.serviceContainer.cryptoService,
);
this.sendEditCommand = new SendEditCommand(
this.serviceContainer.sendService,
this.sendGetCommand,
this.serviceContainer.sendApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.sendListCommand = new SendListCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.searchService,
);
this.sendRemovePasswordCommand = new SendRemovePasswordCommand(
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
);
}
constructor(
protected serviceContainer: ServiceContainer,
protected serveConfigurator: OssServeConfigurator,
) {}
async run(options: OptionValues) {
const protectOrigin = !options.disableOriginProtection;
@@ -205,207 +48,7 @@ export class ServeCommand {
.use(koaBodyParser())
.use(koaJson({ pretty: false, param: "pretty" }));
router.get("/generate", async (ctx, next) => {
const response = await this.generateCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.get("/status", async (ctx, next) => {
const response = await this.statusCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.get("/list/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendListCommand.run(ctx.request.query);
} else {
response = await this.listCommand.run(ctx.params.object, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/send/list", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendListCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/sync", async (ctx, next) => {
const response = await this.syncCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/lock", async (ctx, next) => {
const response = await this.lockCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.post("/unlock", async (ctx, next) => {
// Do not allow guessing password location through serve command
delete ctx.request.query.passwordFile;
delete ctx.request.query.passwordEnv;
const response = await this.unlockCommand.run(
ctx.request.body.password == null ? null : (ctx.request.body.password as string),
ctx.request.query,
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/confirm/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.confirmCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query,
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/restore/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.restoreCommand.run(ctx.params.object, ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/move/:id/:organizationId", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.shareCommand.run(
ctx.params.id,
ctx.params.organizationId,
ctx.request.body, // TODO: Check the format of this body for an array of collection ids
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/attachment", koaMulter().single("file"), async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.createCommand.run(
"attachment",
ctx.request.body,
ctx.request.query,
{
fileBuffer: ctx.request.file.buffer,
fileName: ctx.request.file.originalname,
},
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/send/:id/remove-password", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendRemovePasswordCommand.run(ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendCreateCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.createCommand.run(
ctx.params.object,
ctx.request.body,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
router.put("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
ctx.request.body.id = ctx.params.id;
response = await this.sendEditCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.editCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.body,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendGetCommand.run(ctx.params.id, null);
} else {
response = await this.getCommand.run(ctx.params.object, ctx.params.id, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.delete("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendDeleteCommand.run(ctx.params.id);
} else {
response = await this.deleteCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
this.serveConfigurator.configureRouter(router);
server
.use(router.routes())
@@ -414,31 +57,4 @@ export class ServeCommand {
this.serviceContainer.logService.info("Listening on " + hostname + ":" + port);
});
}
private processResponse(res: koa.Response, commandResponse: Response) {
if (!commandResponse.success) {
res.status = 400;
}
if (commandResponse.data instanceof FileResponse) {
res.body = commandResponse.data.data;
res.attachment(commandResponse.data.fileName);
res.set("Content-Type", "application/octet-stream");
res.set("Content-Length", commandResponse.data.data.length.toString());
} else {
res.body = commandResponse;
}
}
private async errorIfLocked(res: koa.Response) {
const authed = await this.serviceContainer.stateService.getIsAuthenticated();
if (!authed) {
this.processResponse(res, Response.error("You are not logged in."));
return true;
}
if (await this.serviceContainer.cryptoService.hasUserKey()) {
return false;
}
this.processResponse(res, Response.error("Vault is locked."));
return true;
}
}

View File

@@ -0,0 +1,399 @@
import * as koaMulter from "@koa/multer";
import * as koaRouter from "@koa/router";
import * as koa from "koa";
import { ConfirmCommand } from "./admin-console/commands/confirm.command";
import { ShareCommand } from "./admin-console/commands/share.command";
import { LockCommand } from "./auth/commands/lock.command";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { EditCommand } from "./commands/edit.command";
import { GetCommand } from "./commands/get.command";
import { ListCommand } from "./commands/list.command";
import { RestoreCommand } from "./commands/restore.command";
import { StatusCommand } from "./commands/status.command";
import { Response } from "./models/response";
import { FileResponse } from "./models/response/file.response";
import { ServiceContainer } from "./service-container";
import { GenerateCommand } from "./tools/generate.command";
import {
SendEditCommand,
SendCreateCommand,
SendDeleteCommand,
SendGetCommand,
SendListCommand,
SendRemovePasswordCommand,
} from "./tools/send";
import { CreateCommand } from "./vault/create.command";
import { DeleteCommand } from "./vault/delete.command";
import { SyncCommand } from "./vault/sync.command";
export class OssServeConfigurator {
private listCommand: ListCommand;
private getCommand: GetCommand;
private createCommand: CreateCommand;
private editCommand: EditCommand;
private generateCommand: GenerateCommand;
private shareCommand: ShareCommand;
private statusCommand: StatusCommand;
private syncCommand: SyncCommand;
private deleteCommand: DeleteCommand;
private confirmCommand: ConfirmCommand;
private restoreCommand: RestoreCommand;
private lockCommand: LockCommand;
private unlockCommand: UnlockCommand;
private sendCreateCommand: SendCreateCommand;
private sendDeleteCommand: SendDeleteCommand;
private sendEditCommand: SendEditCommand;
private sendGetCommand: SendGetCommand;
private sendListCommand: SendListCommand;
private sendRemovePasswordCommand: SendRemovePasswordCommand;
constructor(protected serviceContainer: ServiceContainer) {
this.getCommand = new GetCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.collectionService,
this.serviceContainer.totpService,
this.serviceContainer.auditService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,
this.serviceContainer.eventCollectionService,
this.serviceContainer.billingAccountProfileStateService,
);
this.listCommand = new ListCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.collectionService,
this.serviceContainer.organizationService,
this.serviceContainer.searchService,
this.serviceContainer.organizationUserService,
this.serviceContainer.apiService,
this.serviceContainer.eventCollectionService,
);
this.createCommand = new CreateCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.cryptoService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
this.serviceContainer.organizationService,
);
this.editCommand = new EditCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.cryptoService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
);
this.generateCommand = new GenerateCommand(
this.serviceContainer.passwordGenerationService,
this.serviceContainer.stateService,
);
this.syncCommand = new SyncCommand(this.serviceContainer.syncService);
this.statusCommand = new StatusCommand(
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.accountService,
this.serviceContainer.authService,
);
this.deleteCommand = new DeleteCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.apiService,
this.serviceContainer.folderApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.confirmCommand = new ConfirmCommand(
this.serviceContainer.apiService,
this.serviceContainer.cryptoService,
this.serviceContainer.organizationUserService,
);
this.restoreCommand = new RestoreCommand(this.serviceContainer.cipherService);
this.shareCommand = new ShareCommand(this.serviceContainer.cipherService);
this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService);
this.unlockCommand = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,
this.serviceContainer.cryptoService,
this.serviceContainer.stateService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.apiService,
this.serviceContainer.logService,
this.serviceContainer.keyConnectorService,
this.serviceContainer.environmentService,
this.serviceContainer.syncService,
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.kdfConfigService,
);
this.sendCreateCommand = new SendCreateCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.sendApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.sendDeleteCommand = new SendDeleteCommand(
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
);
this.sendGetCommand = new SendGetCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.searchService,
this.serviceContainer.cryptoService,
);
this.sendEditCommand = new SendEditCommand(
this.serviceContainer.sendService,
this.sendGetCommand,
this.serviceContainer.sendApiService,
this.serviceContainer.billingAccountProfileStateService,
);
this.sendListCommand = new SendListCommand(
this.serviceContainer.sendService,
this.serviceContainer.environmentService,
this.serviceContainer.searchService,
);
this.sendRemovePasswordCommand = new SendRemovePasswordCommand(
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
);
}
configureRouter(router: koaRouter) {
router.get("/generate", async (ctx, next) => {
const response = await this.generateCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.get("/status", async (ctx, next) => {
const response = await this.statusCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.get("/list/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendListCommand.run(ctx.request.query);
} else {
response = await this.listCommand.run(ctx.params.object, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/send/list", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendListCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/sync", async (ctx, next) => {
const response = await this.syncCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/lock", async (ctx, next) => {
const response = await this.lockCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.post("/unlock", async (ctx, next) => {
// Do not allow guessing password location through serve command
delete ctx.request.query.passwordFile;
delete ctx.request.query.passwordEnv;
const response = await this.unlockCommand.run(
ctx.request.body.password == null ? null : (ctx.request.body.password as string),
ctx.request.query,
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/confirm/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.confirmCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query,
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/restore/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.restoreCommand.run(ctx.params.object, ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/move/:id/:organizationId", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.shareCommand.run(
ctx.params.id,
ctx.params.organizationId,
ctx.request.body, // TODO: Check the format of this body for an array of collection ids
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/attachment", koaMulter().single("file"), async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.createCommand.run(
"attachment",
ctx.request.body,
ctx.request.query,
{
fileBuffer: ctx.request.file.buffer,
fileName: ctx.request.file.originalname,
},
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/send/:id/remove-password", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendRemovePasswordCommand.run(ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendCreateCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.createCommand.run(
ctx.params.object,
ctx.request.body,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
router.put("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
ctx.request.body.id = ctx.params.id;
response = await this.sendEditCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.editCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.body,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendGetCommand.run(ctx.params.id, null);
} else {
response = await this.getCommand.run(ctx.params.object, ctx.params.id, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.delete("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendDeleteCommand.run(ctx.params.id);
} else {
response = await this.deleteCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query,
);
}
this.processResponse(ctx.response, response);
await next();
});
}
protected processResponse(res: koa.Response, commandResponse: Response) {
if (!commandResponse.success) {
res.status = 400;
}
if (commandResponse.data instanceof FileResponse) {
res.body = commandResponse.data.data;
res.attachment(commandResponse.data.fileName);
res.set("Content-Type", "application/octet-stream");
res.set("Content-Length", commandResponse.data.data.length.toString());
} else {
res.body = commandResponse;
}
}
protected async errorIfLocked(res: koa.Response) {
const authed = await this.serviceContainer.stateService.getIsAuthenticated();
if (!authed) {
this.processResponse(res, Response.error("You are not logged in."));
return true;
}
if (await this.serviceContainer.cryptoService.hasUserKey()) {
return false;
}
this.processResponse(res, Response.error("Vault is locked."));
return true;
}
}

View File

@@ -12,7 +12,6 @@ import { BaseProgram } from "./base-program";
import { CompletionCommand } from "./commands/completion.command";
import { ConfigCommand } from "./commands/config.command";
import { EncodeCommand } from "./commands/encode.command";
import { ServeCommand } from "./commands/serve.command";
import { StatusCommand } from "./commands/status.command";
import { UpdateCommand } from "./commands/update.command";
import { Response } from "./models/response";
@@ -487,34 +486,5 @@ export class Program extends BaseProgram {
const response = await command.run();
this.processResponse(response);
});
program
.command("serve")
.description("Start a RESTful API webserver.")
.option("--hostname <hostname>", "The hostname to bind your API webserver to.")
.option("--port <port>", "The port to run your API webserver on.")
.option(
"--disable-origin-protection",
"If set, allows requests with origin header. Warning, this option exists for backwards compatibility reasons and exposes your environment to known CSRF attacks.",
)
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Default hostname is `localhost`.");
writeLn(" Use hostname `all` for no hostname binding.");
writeLn(" Default port is `8087`.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw serve");
writeLn(" bw serve --port 8080");
writeLn(" bw serve --hostname bwapi.mydomain.com --port 80");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfNotAuthed();
const command = new ServeCommand(this.serviceContainer);
await command.run(cmd);
});
}
}

View File

@@ -0,0 +1,49 @@
import { program } from "commander";
import { BaseProgram } from "./base-program";
import { ServeCommand } from "./commands/serve.command";
import { OssServeConfigurator } from "./oss-serve-configurator";
import { ServiceContainer } from "./service-container";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;
export class ServeProgram extends BaseProgram {
constructor(
serviceContainer: ServiceContainer,
private configurator: OssServeConfigurator,
) {
super(serviceContainer);
}
register() {
program
.command("serve")
.description("Start a RESTful API webserver.")
.option("--hostname <hostname>", "The hostname to bind your API webserver to.")
.option("--port <port>", "The port to run your API webserver on.")
.option(
"--disable-origin-protection",
"If set, allows requests with origin header. Warning, this option exists for backwards compatibility reasons and exposes your environment to known CSRF attacks.",
)
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Default hostname is `localhost`.");
writeLn(" Use hostname `all` for no hostname binding.");
writeLn(" Default port is `8087`.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw serve");
writeLn(" bw serve --port 8080");
writeLn(" bw serve --hostname bwapi.mydomain.com --port 80");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfNotAuthed();
const command = new ServeCommand(this.serviceContainer, this.configurator);
await command.run(cmd);
});
}
}

View File

@@ -0,0 +1,415 @@
import { Directive, ViewChild, ViewContainerRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { firstValueFrom, lastValueFrom, debounceTime } from "rxjs";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
import {
OrganizationUserStatusType,
OrganizationUserType,
ProviderUserStatusType,
ProviderUserType,
} from "@bitwarden/common/admin-console/enums";
import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DialogService, TableDataSource } from "@bitwarden/components";
import { OrganizationUserView } from "../organizations/core/views/organization-user.view";
import { UserConfirmComponent } from "../organizations/manage/user-confirm.component";
type StatusType = OrganizationUserStatusType | ProviderUserStatusType;
const MaxCheckedCount = 500;
/**
* A refactored copy of BasePeopleComponent, using the component library table and other modern features.
* This will replace BasePeopleComponent once all subclasses have been changed over to use this class.
*/
@Directive()
export abstract class NewBasePeopleComponent<
UserView extends ProviderUserUserDetailsResponse | OrganizationUserView,
> {
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
confirmModalRef: ViewContainerRef;
get allCount() {
return this.activeUsers != null ? this.activeUsers.length : 0;
}
get invitedCount() {
return this.statusMap.has(this.userStatusType.Invited)
? this.statusMap.get(this.userStatusType.Invited).length
: 0;
}
get acceptedCount() {
return this.statusMap.has(this.userStatusType.Accepted)
? this.statusMap.get(this.userStatusType.Accepted).length
: 0;
}
get confirmedCount() {
return this.statusMap.has(this.userStatusType.Confirmed)
? this.statusMap.get(this.userStatusType.Confirmed).length
: 0;
}
get revokedCount() {
return this.statusMap.has(this.userStatusType.Revoked)
? this.statusMap.get(this.userStatusType.Revoked).length
: 0;
}
/**
* Shows a banner alerting the admin that users need to be confirmed.
*/
get showConfirmUsers(): boolean {
return (
this.activeUsers != null &&
this.statusMap != null &&
this.activeUsers.length > 1 &&
this.confirmedCount > 0 &&
this.confirmedCount < 3 &&
this.acceptedCount > 0
);
}
get showBulkConfirmUsers(): boolean {
return this.acceptedCount > 0;
}
abstract userType: typeof OrganizationUserType | typeof ProviderUserType;
abstract userStatusType: typeof OrganizationUserStatusType | typeof ProviderUserStatusType;
protected dataSource = new TableDataSource<UserView>();
firstLoaded: boolean;
/**
* A hashmap that groups users by their status (invited/accepted/etc). This is used by the toggles to show
* user counts and filter data by user status.
*/
statusMap = new Map<StatusType, UserView[]>();
/**
* The currently selected status filter, or null to show all active users.
*/
status: StatusType | null;
/**
* The currently executing promise - used to avoid multiple user actions executing at once.
*/
actionPromise: Promise<void>;
/**
* All users, loaded from the server, before any filtering has been applied.
*/
protected allUsers: UserView[] = [];
/**
* Active users only, that is, users that are not in the revoked status.
*/
protected activeUsers: UserView[] = [];
protected searchControl = new FormControl("", { nonNullable: true });
constructor(
protected apiService: ApiService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected cryptoService: CryptoService,
protected validationService: ValidationService,
protected modalService: ModalService,
private logService: LogService,
protected userNamePipe: UserNamePipe,
protected dialogService: DialogService,
protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
) {
// Connect the search input to the table dataSource filter input
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((v) => (this.dataSource.filter = v));
}
abstract edit(user: UserView): void;
abstract getUsers(): Promise<ListResponse<UserView> | UserView[]>;
abstract deleteUser(id: string): Promise<void>;
abstract revokeUser(id: string): Promise<void>;
abstract restoreUser(id: string): Promise<void>;
abstract reinviteUser(id: string): Promise<void>;
abstract confirmUser(user: UserView, publicKey: Uint8Array): Promise<void>;
async load() {
// Load new users from the server
const response = await this.getUsers();
// Reset and repopulate the statusMap
this.statusMap.clear();
this.activeUsers = [];
for (const status of Utils.iterateEnum(this.userStatusType)) {
this.statusMap.set(status, []);
}
if (response instanceof ListResponse) {
this.allUsers = response.data != null && response.data.length > 0 ? response.data : [];
} else if (Array.isArray(response)) {
this.allUsers = response;
}
this.allUsers.forEach((u) => {
if (!this.statusMap.has(u.status)) {
this.statusMap.set(u.status, [u]);
} else {
this.statusMap.get(u.status).push(u);
}
if (u.status !== this.userStatusType.Revoked) {
this.activeUsers.push(u);
}
});
// Filter based on UserStatus - this also populates the table on first load
this.filter(this.status);
this.firstLoaded = true;
}
/**
* Filter the data source by user status.
* This overwrites dataSource.data because this filtering needs to apply first, before the search input
*/
filter(status: StatusType | null) {
this.status = status;
if (this.status != null) {
this.dataSource.data = this.statusMap.get(this.status);
} else {
this.dataSource.data = this.activeUsers;
}
// Reset checkbox selection
this.selectAll(false);
}
checkUser(user: UserView, select?: boolean) {
(user as any).checked = select == null ? !(user as any).checked : select;
}
selectAll(select: boolean) {
if (select) {
// Reset checkbox selection first so we know nothing else is selected
this.selectAll(false);
}
const filteredUsers = this.dataSource.filteredData;
const selectCount =
select && filteredUsers.length > MaxCheckedCount ? MaxCheckedCount : filteredUsers.length;
for (let i = 0; i < selectCount; i++) {
this.checkUser(filteredUsers[i], select);
}
}
invite() {
this.edit(null);
}
protected async removeUserConfirmationDialog(user: UserView) {
return this.dialogService.openSimpleDialog({
title: this.userNamePipe.transform(user),
content: { key: "removeUserConfirmation" },
type: "warning",
});
}
async remove(user: UserView) {
const confirmed = await this.removeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.deleteUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("removedUserId", this.userNamePipe.transform(user)),
);
this.removeUser(user);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
protected async revokeUserConfirmationDialog(user: UserView) {
return this.dialogService.openSimpleDialog({
title: { key: "revokeAccess", placeholders: [this.userNamePipe.transform(user)] },
content: this.revokeWarningMessage(),
acceptButtonText: { key: "revokeAccess" },
type: "warning",
});
}
async revoke(user: UserView) {
const confirmed = await this.revokeUserConfirmationDialog(user);
if (!confirmed) {
return false;
}
this.actionPromise = this.revokeUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)),
);
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async restore(user: UserView) {
this.actionPromise = this.restoreUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)),
);
await this.load();
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async reinvite(user: UserView) {
if (this.actionPromise != null) {
return;
}
this.actionPromise = this.reinviteUser(user.id);
try {
await this.actionPromise;
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("hasBeenReinvited", this.userNamePipe.transform(user)),
);
} catch (e) {
this.validationService.showError(e);
}
this.actionPromise = null;
}
async confirm(user: UserView) {
function updateUser(self: NewBasePeopleComponent<UserView>) {
user.status = self.userStatusType.Confirmed;
const mapIndex = self.statusMap.get(self.userStatusType.Accepted).indexOf(user);
if (mapIndex > -1) {
self.statusMap.get(self.userStatusType.Accepted).splice(mapIndex, 1);
self.statusMap.get(self.userStatusType.Confirmed).push(user);
}
}
const confirmUser = async (publicKey: Uint8Array) => {
try {
this.actionPromise = this.confirmUser(user, publicKey);
await this.actionPromise;
updateUser(this);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("hasBeenConfirmed", this.userNamePipe.transform(user)),
);
} catch (e) {
this.validationService.showError(e);
throw e;
} finally {
this.actionPromise = null;
}
};
if (this.actionPromise != null) {
return;
}
try {
const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const autoConfirm = await firstValueFrom(
this.organizationManagementPreferencesService.autoConfirmFingerPrints.state$,
);
if (autoConfirm == null || !autoConfirm) {
const dialogRef = UserConfirmComponent.open(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
userId: user != null ? user.userId : null,
publicKey: publicKey,
confirmUser: () => confirmUser(publicKey),
},
});
await lastValueFrom(dialogRef.closed);
return;
}
try {
const fingerprint = await this.cryptoService.getFingerprint(user.userId, publicKey);
this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`);
} catch (e) {
this.logService.error(e);
}
await confirmUser(publicKey);
} catch (e) {
this.logService.error(`Handled exception: ${e}`);
}
}
protected revokeWarningMessage(): string {
return this.i18nService.t("revokeUserConfirmation");
}
protected getCheckedUsers() {
return this.dataSource.data.filter((u) => (u as any).checked);
}
/**
* Remove a user row from the table and all related data sources
*/
protected removeUser(user: UserView) {
let index = this.dataSource.data.indexOf(user);
if (index > -1) {
// Clone the array so that the setter for dataSource.data is triggered to update the table rendering
const updatedData = [...this.dataSource.data];
updatedData.splice(index, 1);
this.dataSource.data = updatedData;
}
index = this.allUsers.indexOf(user);
if (index > -1) {
this.allUsers.splice(index, 1);
}
if (this.statusMap.has(user.status)) {
index = this.statusMap.get(user.status).indexOf(user);
if (index > -1) {
this.statusMap.get(user.status).splice(index, 1);
}
}
}
}

View File

@@ -1,3 +1,4 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { NgModule } from "@angular/core";
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
@@ -22,6 +23,7 @@ import { PeopleComponent } from "./people.component";
MembersRoutingModule,
UserDialogModule,
PasswordCalloutComponent,
ScrollingModule,
],
declarations: [
BulkConfirmComponent,

View File

@@ -37,7 +37,7 @@
</bit-toggle>
</bit-toggle-group>
</div>
<ng-container *ngIf="loading">
<ng-container *ngIf="!firstLoaded">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
@@ -45,16 +45,9 @@
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="
!loading &&
((isPaging$ | async)
? pagedUsers
: (users | search: searchControl.value : 'name' : 'email' : 'id')) as searchedUsers
"
>
<p *ngIf="!searchedUsers.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="searchedUsers.length">
<ng-container *ngIf="firstLoaded">
<p *ngIf="!dataSource.filteredData.length">{{ "noMembersInList" | i18n }}</p>
<ng-container *ngIf="dataSource.filteredData.length">
<app-callout
type="info"
title="{{ 'confirmUsers' | i18n }}"
@@ -63,256 +56,262 @@
>
{{ "usersNeedConfirmed" | i18n }}
</app-callout>
<bit-table
infinite-scroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!(isPaging$ | async)"
(scrolled)="loadMore()"
>
<ng-container header>
<tr>
<th bitCell class="tw-w-20">
<input
type="checkbox"
bitCheckbox
class="tw-mr-1"
(change)="selectAll($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ (organization.useGroups ? "groups" : "collections") | i18n }}</th>
<th bitCell>{{ "role" | i18n }}</th>
<th bitCell>{{ "policies" | i18n }}</th>
<th bitCell class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager$ | async">
<button type="button" bitMenuItem (click)="bulkEnableSM()">
{{ "activateSecretsManager" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button type="button" bitMenuItem (click)="bulkReinvite()">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<!-- 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 scrollWindow [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)="selectAll($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
<th bitCell 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 class="tw-w-10">
<button
[bitMenuTriggerFor]="headerMenu"
type="button"
bitMenuItem
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button type="button" bitMenuItem (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRevoke()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRemove()">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow *ngFor="let u of searchedUsers" alignContent="middle">
<td bitCell (click)="checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
bitIconButton="bwi-ellipsis-v"
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<button type="button" bitLink>
{{ u.name ?? u.email }}
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #headerMenu>
<ng-container *ngIf="canUseSecretsManager$ | async">
<button type="button" bitMenuItem (click)="bulkEnableSM()">
{{ "activateSecretsManager" | i18n }}
</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 }}
<bit-menu-divider></bit-menu-divider>
</ng-container>
<button type="button" bitMenuItem (click)="bulkReinvite()">
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
{{ "reinviteSelected" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="bulkConfirm()"
*ngIf="showBulkConfirmUsers"
>
<span class="tw-text-success">
<i class="bwi bwi-fw bwi-check" aria-hidden="true"></i>
{{ "confirmSelected" | i18n }}
</span>
</button>
<button type="button" bitMenuItem (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "restoreAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRevoke()">
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="bulkRemove()">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i>
{{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr
bitRow
*cdkVirtualFor="let u of rows$"
alignContent="middle"
[ngClass]="rowHeightClass"
>
<td bitCell (click)="checkUser(u)">
<input type="checkbox" bitCheckbox [(ngModel)]="$any(u).checked" />
</td>
<td bitCell (click)="edit(u)" class="tw-cursor-pointer">
<div class="tw-flex tw-items-center">
<bit-avatar
size="small"
[text]="u | userName"
[id]="u.userId"
[color]="u.avatarColor"
class="tw-mr-3"
></bit-avatar>
<div class="tw-flex tw-flex-col">
<div>
<button 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>
</div>
</td>
</td>
<td
bitCell
(click)="edit(u, 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>
<td
bitCell
(click)="edit(u, 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>
<td
bitCell
(click)="edit(u, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
<td
bitCell
(click)="edit(u, memberTab.Role)"
class="tw-cursor-pointer tw-text-sm tw-text-muted"
>
{{ u.type | userType }}
</td>
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u))">
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<td bitCell class="tw-text-muted">
<ng-container *ngIf="u.twoFactorEnabled">
<i
class="bwi bwi-lock"
title="{{ 'userUsingTwoStep' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "userUsingTwoStep" | i18n }}</span>
</ng-container>
<ng-container *ngIf="showEnrolledStatus($any(u))">
<i
class="bwi bwi-key"
title="{{ 'enrolledAccountRecovery' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "enrolledAccountRecovery" | i18n }}</span>
</ng-container>
</td>
<td bitCell>
<button
[bitMenuTriggerFor]="rowMenu"
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="u.status === userStatusType.Accepted || u.status === userStatusType.Invited"
></bit-menu-divider>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Role)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Collections)">
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="events(u)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(u)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
<bit-menu #rowMenu>
<button
type="button"
bitMenuItem
(click)="reinvite(u)"
*ngIf="u.status === userStatusType.Invited"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="confirm(u)"
*ngIf="u.status === userStatusType.Accepted"
>
<span class="tw-text-success">
<i aria-hidden="true" class="bwi bwi-check"></i> {{ "confirm" | i18n }}
</span>
</button>
<bit-menu-divider
*ngIf="
u.status === userStatusType.Accepted || u.status === userStatusType.Invited
"
></bit-menu-divider>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Role)">
<i aria-hidden="true" class="bwi bwi-user"></i> {{ "memberRole" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="edit(u, memberTab.Groups)"
*ngIf="organization.useGroups"
>
<i aria-hidden="true" class="bwi bwi-users"></i> {{ "groups" | i18n }}
</button>
<button type="button" bitMenuItem (click)="edit(u, memberTab.Collections)">
<i aria-hidden="true" class="bwi bwi-collection"></i> {{ "collections" | i18n }}
</button>
<bit-menu-divider></bit-menu-divider>
<button
type="button"
bitMenuItem
(click)="openEventsDialog(u)"
*ngIf="organization.useEvents && u.status === userStatusType.Confirmed"
>
<i aria-hidden="true" class="bwi bwi-file-text"></i> {{ "eventLogs" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="resetPassword(u)"
*ngIf="allowResetPassword(u)"
>
<i aria-hidden="true" class="bwi bwi-key"></i> {{ "recoverAccount" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="restore(u)"
*ngIf="u.status === userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-plus-circle"></i>
{{ "restoreAccess" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="revoke(u)"
*ngIf="u.status !== userStatusType.Revoked"
>
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
{{ "revokeAccess" | i18n }}
</button>
<button type="button" bitMenuItem (click)="remove(u)">
<span class="tw-text-danger">
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
</ng-container>
</ng-container>
<ng-template #addEdit></ng-template>

View File

@@ -1,4 +1,5 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import {
combineLatest,
@@ -9,16 +10,12 @@ import {
map,
Observable,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe";
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
@@ -50,7 +47,7 @@ import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/respon
import { DialogService, SimpleDialogOptions } from "@bitwarden/components";
import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component";
import { BasePeopleComponent } from "../../common/base.people.component";
import { NewBasePeopleComponent } from "../../common/new-base.people.component";
import { GroupService } from "../core";
import { OrganizationUserView } from "../core/views/organization-user.view";
@@ -70,7 +67,7 @@ import { ResetPasswordComponent } from "./components/reset-password.component";
selector: "app-org-people",
templateUrl: "people.component.html",
})
export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
export class PeopleComponent extends NewBasePeopleComponent<OrganizationUserView> {
@ViewChild("groupsTemplate", { read: ViewContainerRef, static: true })
groupsModalRef: ViewContainerRef;
@ViewChild("confirmTemplate", { read: ViewContainerRef, static: true })
@@ -95,7 +92,9 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
protected canUseSecretsManager$: Observable<boolean>;
private destroy$ = new Subject<void>();
// Fixed sizes used for cdkVirtualScroll
protected rowHeight = 62;
protected rowHeightClass = `tw-h-[62px]`;
constructor(
apiService: ApiService,
@@ -104,12 +103,10 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
modalService: ModalService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService,
searchService: SearchService,
validationService: ValidationService,
private policyService: PolicyService,
private policyApiService: PolicyApiService,
logService: LogService,
searchPipe: SearchPipe,
userNamePipe: UserNamePipe,
private syncService: SyncService,
private organizationService: OrganizationService,
@@ -124,21 +121,17 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
) {
super(
apiService,
searchService,
i18nService,
platformUtilsService,
cryptoService,
validationService,
modalService,
logService,
searchPipe,
userNamePipe,
dialogService,
organizationManagementPreferencesService,
);
}
async ngOnInit() {
const organization$ = this.route.params.pipe(
concatMap((params) => this.organizationService.get$(params.organizationId)),
shareReplay({ refCount: true, bufferSize: 1 }),
@@ -198,29 +191,19 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
await this.load();
this.searchControl.setValue(qParams.search);
if (qParams.viewEvents != null) {
const user = this.users.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) {
// 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
this.events(user[0]);
this.openEventsDialog(user[0]);
}
}
}),
takeUntil(this.destroy$),
takeUntilDestroyed(),
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async load() {
await super.load();
}
async getUsers(): Promise<OrganizationUserView[]> {
let groupsPromise: Promise<Map<string, string>>;
let collectionsPromise: Promise<Map<string, string>>;
@@ -593,8 +576,8 @@ export class PeopleComponent extends BasePeopleComponent<OrganizationUserView> {
await this.load();
}
async events(user: OrganizationUserView) {
await openEntityEventsDialog(this.dialogService, {
openEventsDialog(user: OrganizationUserView) {
openEntityEventsDialog(this.dialogService, {
data: {
name: this.userNamePipe.transform(user),
organizationId: this.organization.id,