mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 17:53:39 +00:00
[PM-24534] Archive via CLI (#16502)
* refactor `canInteract` into a component level usage. - The default service is going to be used in the CLI which won't make use of the UI-related aspects * all nested entities to be imported from the vault * initial add of archive command to the cli * add archive to oss serve * check for deleted cipher when attempting to archive * add searchability/list functionality for archived ciphers * restore an archived cipher * unarchive a cipher when a user is editing it and has lost their premium status * add missing feature flags * re-export only needed services from the vault * add needed await * add prompt when applicable for editing an archived cipher * move cipher archive service into `common/vault` * fix testing code
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as inquirer from "inquirer";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { UpdateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||
@@ -9,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { CipherExport } from "@bitwarden/common/models/export/cipher.export";
|
||||
import { CollectionExport } from "@bitwarden/common/models/export/collection.export";
|
||||
@@ -40,6 +42,7 @@ export class EditCommand {
|
||||
private accountService: AccountService,
|
||||
private cliRestrictedItemTypesService: CliRestrictedItemTypesService,
|
||||
private policyService: PolicyService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
@@ -92,6 +95,10 @@ export class EditCommand {
|
||||
private async editCipher(id: string, req: CipherExport) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipher = await this.cipherService.get(id, activeUserId);
|
||||
const hasPremium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
|
||||
);
|
||||
|
||||
if (cipher == null) {
|
||||
return Response.notFound();
|
||||
}
|
||||
@@ -102,6 +109,17 @@ export class EditCommand {
|
||||
}
|
||||
cipherView = CipherExport.toView(req, cipherView);
|
||||
|
||||
// When a user is editing an archived cipher and does not have premium, automatically unarchive it
|
||||
if (cipherView.isArchived && !hasPremium) {
|
||||
const acceptedPrompt = await this.promptForArchiveEdit();
|
||||
|
||||
if (!acceptedPrompt) {
|
||||
return Response.error("Edit cancelled.");
|
||||
}
|
||||
|
||||
cipherView.archivedDate = null;
|
||||
}
|
||||
|
||||
const isCipherRestricted =
|
||||
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
|
||||
if (isCipherRestricted) {
|
||||
@@ -240,6 +258,38 @@ export class EditCommand {
|
||||
return Response.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt the user to accept movement of their cipher back to the their vault. */
|
||||
private async promptForArchiveEdit(): Promise<boolean> {
|
||||
// When running in serve or no interaction mode, automatically accept the prompt
|
||||
if (process.env.BW_SERVE === "true" || process.env.BW_NOINTERACTION === "true") {
|
||||
CliUtils.writeLn(
|
||||
"Archive is only available with a Premium subscription, which has ended. Your edit was saved and the item was moved back to your vault.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const answer: inquirer.Answers = await inquirer.createPromptModule({
|
||||
output: process.stderr,
|
||||
})({
|
||||
type: "list",
|
||||
name: "confirm",
|
||||
message:
|
||||
"When you edit and save details for an archived item without a Premium subscription, it'll be moved from your archive back to your vault.",
|
||||
choices: [
|
||||
{
|
||||
name: "Move now",
|
||||
value: "confirmed",
|
||||
},
|
||||
{
|
||||
name: "Cancel",
|
||||
value: "cancel",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return answer.confirm === "confirmed";
|
||||
}
|
||||
}
|
||||
|
||||
class Options {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { ListResponse as ApiListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -45,6 +46,7 @@ export class ListCommand {
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
private cliRestrictedItemTypesService: CliRestrictedItemTypesService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
) {}
|
||||
|
||||
async run(object: string, cmdOptions: Record<string, any>): Promise<Response> {
|
||||
@@ -71,8 +73,13 @@ export class ListCommand {
|
||||
let ciphers: CipherView[];
|
||||
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const userCanArchive = await firstValueFrom(
|
||||
this.cipherArchiveService.userCanArchive$(activeUserId),
|
||||
);
|
||||
|
||||
options.trash = options.trash || false;
|
||||
options.archived = userCanArchive && options.archived;
|
||||
|
||||
if (options.url != null && options.url.trim() !== "") {
|
||||
ciphers = await this.cipherService.getAllDecryptedForUrl(options.url, activeUserId);
|
||||
} else {
|
||||
@@ -85,9 +92,12 @@ export class ListCommand {
|
||||
options.organizationId != null
|
||||
) {
|
||||
ciphers = ciphers.filter((c) => {
|
||||
if (options.trash !== c.isDeleted) {
|
||||
const matchesStateOptions = this.matchesStateOptions(c, options);
|
||||
|
||||
if (!matchesStateOptions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.folderId != null) {
|
||||
if (options.folderId === "notnull" && c.folderId != null) {
|
||||
return true;
|
||||
@@ -131,11 +141,16 @@ export class ListCommand {
|
||||
return false;
|
||||
});
|
||||
} else if (options.search == null || options.search.trim() === "") {
|
||||
ciphers = ciphers.filter((c) => options.trash === c.isDeleted);
|
||||
ciphers = ciphers.filter((c) => this.matchesStateOptions(c, options));
|
||||
}
|
||||
|
||||
if (options.search != null && options.search.trim() !== "") {
|
||||
ciphers = this.searchService.searchCiphersBasic(ciphers, options.search, options.trash);
|
||||
ciphers = this.searchService.searchCiphersBasic(
|
||||
ciphers,
|
||||
options.search,
|
||||
options.trash,
|
||||
options.archived,
|
||||
);
|
||||
}
|
||||
|
||||
ciphers = await this.cliRestrictedItemTypesService.filterRestrictedCiphers(ciphers);
|
||||
@@ -287,6 +302,17 @@ export class ListCommand {
|
||||
const res = new ListResponse(organizations.map((o) => new OrganizationResponse(o)));
|
||||
return Response.success(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cipher passes either the trash or the archive options.
|
||||
* @returns true if the cipher passes *any* of the filters
|
||||
*/
|
||||
private matchesStateOptions(c: CipherView, options: Options): boolean {
|
||||
const passesTrashFilter = options.trash && c.isDeleted;
|
||||
const passesArchivedFilter = options.archived && c.isArchived;
|
||||
|
||||
return passesTrashFilter || passesArchivedFilter;
|
||||
}
|
||||
}
|
||||
|
||||
class Options {
|
||||
@@ -296,6 +322,7 @@ class Options {
|
||||
search: string;
|
||||
url: string;
|
||||
trash: boolean;
|
||||
archived: boolean;
|
||||
|
||||
constructor(passedOptions: Record<string, any>) {
|
||||
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
|
||||
@@ -304,5 +331,6 @@ class Options {
|
||||
this.search = passedOptions?.search;
|
||||
this.url = passedOptions?.url;
|
||||
this.trash = CliUtils.convertBooleanOption(passedOptions?.trash);
|
||||
this.archived = CliUtils.convertBooleanOption(passedOptions?.archived);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { Response } from "../models/response";
|
||||
|
||||
@@ -12,6 +18,8 @@ export class RestoreCommand {
|
||||
private cipherService: CipherService,
|
||||
private accountService: AccountService,
|
||||
private cipherAuthorizationService: CipherAuthorizationService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async run(object: string, id: string): Promise<Response> {
|
||||
@@ -30,10 +38,23 @@ export class RestoreCommand {
|
||||
private async restoreCipher(id: string) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipher = await this.cipherService.get(id, activeUserId);
|
||||
const isArchivedVaultEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive),
|
||||
);
|
||||
|
||||
if (cipher == null) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
if (cipher.archivedDate && isArchivedVaultEnabled) {
|
||||
return this.restoreArchivedCipher(cipher, activeUserId);
|
||||
} else {
|
||||
return this.restoreDeletedCipher(cipher, activeUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores a cipher from the trash. */
|
||||
private async restoreDeletedCipher(cipher: Cipher, userId: UserId) {
|
||||
if (cipher.deletedDate == null) {
|
||||
return Response.badRequest("Cipher is not in trash.");
|
||||
}
|
||||
@@ -47,7 +68,17 @@ export class RestoreCommand {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cipherService.restoreWithServer(id, activeUserId);
|
||||
await this.cipherService.restoreWithServer(cipher.id, userId);
|
||||
return Response.success();
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a cipher from the archive vault */
|
||||
private async restoreArchivedCipher(cipher: Cipher, userId: UserId) {
|
||||
try {
|
||||
await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, userId);
|
||||
return Response.success();
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ServeCommand {
|
||||
.use(koaBodyParser())
|
||||
.use(koaJson({ pretty: false, param: "pretty" }));
|
||||
|
||||
this.serveConfigurator.configureRouter(router);
|
||||
await this.serveConfigurator.configureRouter(router);
|
||||
|
||||
server.use(router.routes()).use(router.allowedMethods());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user