1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-30 16:23:53 +00:00

migrated vault cipher list

This commit is contained in:
Leslie Xiong
2026-01-23 02:53:53 -05:00
parent 2fac696567
commit 5ebba7a4b4
31 changed files with 2641 additions and 672 deletions

View File

@@ -26,7 +26,6 @@ import { UserVerificationComponent } from "./components/user-verification.compon
import { AccountSwitcherComponent } from "./layout/account-switcher.component";
import { HeaderComponent } from "./layout/header.component";
import { NavComponent } from "./layout/nav.component";
import { SearchComponent } from "./layout/search/search.component";
import { SharedModule } from "./shared/shared.module";
@NgModule({
@@ -51,7 +50,6 @@ import { SharedModule } from "./shared/shared.module";
ColorPasswordCountPipe,
HeaderComponent,
PremiumComponent,
SearchComponent,
],
providers: [
SshAgentService,

View File

@@ -1,4 +1,4 @@
<bit-layout class="!tw-h-full" rounded>
<bit-layout class="!tw-h-full">
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n" />

View File

@@ -1,4 +1,3 @@
<div class="header">
<app-search></app-search>
<app-account-switcher></app-account-switcher>
</div>

View File

@@ -1,11 +1,4 @@
<div class="search" *ngIf="state.enabled">
<input
type="search"
[placeholder]="state.placeholderText"
id="search"
autocomplete="off"
[formControl]="searchText"
appAutofocus
/>
<i class="bwi bwi-search" aria-hidden="true"></i>
</div>
@if (state.enabled) {
<bit-search [placeholder]="state.placeholderText" [formControl]="searchText" appAutofocus>
</bit-search>
}

View File

@@ -1,10 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { UntypedFormControl } from "@angular/forms";
import { ReactiveFormsModule, UntypedFormControl } from "@angular/forms";
import { Subscription } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofocusDirective, SearchModule } from "@bitwarden/components";
import { SearchBarService, SearchBarState } from "./search-bar.service";
@@ -13,7 +15,7 @@ import { SearchBarService, SearchBarState } from "./search-bar.service";
@Component({
selector: "app-search",
templateUrl: "search.component.html",
standalone: false,
imports: [CommonModule, ReactiveFormsModule, AutofocusDirective, SearchModule],
})
export class SearchComponent implements OnInit, OnDestroy {
state: SearchBarState;

View File

@@ -11,6 +11,9 @@
"favorites": {
"message": "Favorites"
},
"unfavorite": {
"message": "Unfavorite"
},
"types": {
"message": "Types"
},
@@ -47,6 +50,21 @@
"addItem": {
"message": "Add item"
},
"addLogin": {
"message": "Add login"
},
"addCard": {
"message": "Add card"
},
"addIdentity": {
"message": "Add identity"
},
"addSecureNote": {
"message": "Add secure note"
},
"addSshKey": {
"message": "Add SSH key"
},
"shared": {
"message": "Shared"
},
@@ -84,6 +102,21 @@
"viewItem": {
"message": "View item"
},
"viewLogin": {
"message": "View login"
},
"viewCard": {
"message": "View card"
},
"viewIdentity": {
"message": "View identity"
},
"viewSecureNote": {
"message": "View secure note"
},
"viewSshKey": {
"message": "View SSH key"
},
"name": {
"message": "Name"
},
@@ -100,70 +133,6 @@
}
}
},
"deletionDateDescV2": {
"message": "The Send will be permanently deleted on this date.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"fileToShare": {
"message": "File to share"
},
"hideTextByDefault": {
"message": "Hide text by default"
},
"hideYourEmail": {
"message": "Hide your email address from viewers."
},
"limitSendViews": {
"message": "Limit views"
},
"limitSendViewsCount": {
"message": "$ACCESSCOUNT$ views left",
"description": "Displayed under the limit views field on Send",
"placeholders": {
"accessCount": {
"content": "$1",
"example": "2"
}
}
},
"limitSendViewsHint": {
"message": "No one can view this Send after the limit is reached.",
"description": "Displayed under the limit views field on Send"
},
"privateNote": {
"message": "Private note"
},
"sendDetails": {
"message": "Send details",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendTypeTextToShare": {
"message": "Text to share"
},
"newItemHeaderTextSend": {
"message": "New Text Send",
"description": "Header for new text send"
},
"newItemHeaderFileSend": {
"message": "New File Send",
"description": "Header for new file send"
},
"editItemHeaderTextSend": {
"message": "Edit Text Send",
"description": "Header for edit text send"
},
"editItemHeaderFileSend": {
"message": "Edit File Send",
"description": "Header for edit file send"
},
"deleteSendPermanentConfirmation": {
"message": "Are you sure you want to permanently delete this Send?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"new": {
"message": "New",
"description": "for adding new items"
@@ -183,6 +152,21 @@
"editItem": {
"message": "Edit item"
},
"editLogin": {
"message": "Edit login"
},
"editCard": {
"message": "Edit card"
},
"editIdentity": {
"message": "Edit identity"
},
"editSecureNote": {
"message": "Edit secure note"
},
"editSshKey": {
"message": "Edit SSH key"
},
"emailAddress": {
"message": "Email address"
},
@@ -590,6 +574,12 @@
"editedItem": {
"message": "Item saved"
},
"itemAddedToFavorites": {
"message": "Item added to favorites"
},
"itemRemovedFromFavorites": {
"message": "Item removed from favorites"
},
"deleteItem": {
"message": "Delete item"
},
@@ -605,6 +595,15 @@
"deletedItem": {
"message": "Item sent to trash"
},
"deletedCollectionId": {
"message": "Deleted collection $ID$.",
"placeholders": {
"id": {
"content": "$1",
"example": "Server Passwords"
}
}
},
"overwritePasswordConfirmation": {
"message": "Are you sure you want to overwrite the current password?"
},
@@ -815,6 +814,15 @@
"deletedFolder": {
"message": "Folder deleted"
},
"editInfo": {
"message": "Edit info"
},
"access": {
"message": "Access"
},
"editAccess": {
"message": "Edit access"
},
"loginOrCreateNewAccount": {
"message": "Log in or create a new account to access your secure vault."
},
@@ -1556,6 +1564,21 @@
"unknown": {
"message": "Unknown"
},
"unknownCipher": {
"message": "Unknown item, you may need to request permission to access this item."
},
"copyAddress": {
"message": "Copy address"
},
"copyPhone": {
"message": "Copy phone"
},
"copyNote": {
"message": "Copy note"
},
"copyVerificationCode": {
"message": "Copy verification code"
},
"copyUsername": {
"message": "Copy username"
},
@@ -2057,6 +2080,24 @@
"clone": {
"message": "Clone"
},
"cloneItem": {
"message": "Clone item"
},
"cloneLogin": {
"message": "Clone login"
},
"cloneCard": {
"message": "Clone card"
},
"cloneIdentity": {
"message": "Clone identity"
},
"cloneSecureNote": {
"message": "Clone secure note"
},
"cloneSshKey": {
"message": "Clone SSH key"
},
"passwordGeneratorPolicyInEffect": {
"message": "One or more organization policies are affecting your generator settings."
},
@@ -2080,9 +2121,33 @@
"message": "Trash",
"description": "Noun: a special folder to hold deleted items"
},
"trashCleanupWarning": {
"message": "Items that have been in trash more than 30 days will be automatically deleted."
},
"trashCleanupWarningSelfHosted": {
"message": "Items that have been in trash for a while will be automatically deleted."
},
"searchTrash": {
"message": "Search trash"
},
"searchArchive": {
"message": "Search archive"
},
"searchLogin": {
"message": "Search login"
},
"searchCard": {
"message": "Search card"
},
"searchIdentity": {
"message": "Search identity"
},
"searchSecureNote": {
"message": "Search secure note"
},
"searchSshKey": {
"message": "Search SSH key"
},
"permanentlyDeleteItem": {
"message": "Permanently delete item"
},
@@ -2092,9 +2157,6 @@
"permanentlyDeletedItem": {
"message": "Item permanently deleted"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"restoredItem": {
"message": "Item restored"
},
@@ -2379,6 +2441,9 @@
"message": "Edit Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"me": {
"message": "Me"
},
"myVault": {
"message": "My vault"
},
@@ -3780,6 +3845,93 @@
"collection": {
"message": "Collection"
},
"editCollection": {
"message": "Edit collection"
},
"nestCollectionUnder": {
"message": "Nest collection under"
},
"collectionInfo": {
"message": "Collection info"
},
"grantCollectionAccess": {
"message": "Grant groups or members access to this collection."
},
"permission": {
"message": "Permission"
},
"viewItems": {
"message": "View items"
},
"viewItemsHidePass": {
"message": "View items, hidden passwords"
},
"editItems": {
"message": "Edit items"
},
"editItemsHidePass": {
"message": "Edit items, hidden passwords"
},
"manageCollection": {
"message": "Manage collection"
},
"selectGroupsAndMembers": {
"message": "Select groups and members"
},
"newCollection": {
"message": "New collection"
},
"externalId": {
"message": "External ID"
},
"externalIdDesc": {
"message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector and API."
},
"noCollection": {
"message": "No collection"
},
"deleted": {
"message": "Deleted"
},
"readOnlyCollectionAccess": {
"message": "You do not have access to manage this collection."
},
"grantManageCollectionWarningTitle": {
"message": "Missing Manage Collection Permissions"
},
"grantManageCollectionWarning": {
"message": "Grant Manage collection permissions to allow full collection management including deletion of collection."
},
"grantCollectionAccessMembersOnly": {
"message": "Grant members access to this collection."
},
"adminCollectionAccess": {
"message": "Administrators can access and manage collections."
},
"managePermissionRequired": {
"message": "At least one member or group must have can manage permission."
},
"userPermissionOverrideHelperDesc": {
"message": "Permissions set for a member will replace permissions set by that member's group."
},
"noMembersOrGroupsAdded": {
"message": "No members or groups added"
},
"memberColumnHeader": {
"message": "Member"
},
"selectMembers": {
"message": "Select members"
},
"noMembersAdded": {
"message": "No members added"
},
"groupSlashMemberColumnHeader": {
"message": "Group/Member"
},
"deleteCollectionConfirmation": {
"message": "Are you sure you want to delete this collection?"
},
"lastPassYubikeyDesc": {
"message": "Insert the YubiKey associated with your LastPass account into your computer's USB port, then touch its button."
},
@@ -4087,6 +4239,9 @@
"missingWebsite": {
"message": "Missing website"
},
"missingPermissions": {
"message": "You lack the necessary permissions to perform this action."
},
"cannotRemoveViewOnlyCollections": {
"message": "You cannot remove collections with View only permissions: $COLLECTIONS$",
"placeholders": {
@@ -4400,8 +4555,8 @@
"archiveItem": {
"message": "Archive item"
},
"archiveItemDialogContent": {
"message": "Once archived, this item will be excluded from search results and autofill suggestions."
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
},
"unArchiveAndSave": {
"message": "Unarchive and save"
@@ -4588,4 +4743,4 @@
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
}
}
}

View File

@@ -1,7 +1,6 @@
@import "variables.scss";
.btn,
.vault .footer button,
.modal-footer button {
border-radius: $border-radius;
padding: 7px 15px;

View File

@@ -28,9 +28,8 @@ app-root {
> .items {
order: 2;
width: 28%;
width: 50%;
min-width: 200px;
max-width: 350px;
border-right: 1px solid #000000;
@include themify($themes) {

View File

@@ -0,0 +1,50 @@
<div
class="tw-flex tw-items-center tw-bg-bg-secondary tw-border-0 tw-border-t tw-border-solid tw-border-border-base tw-py-[20px] tw-pl-[24px] tw-pr-[12px]"
>
@if (!cipher.decryptionFailure) {
<button
#submitBtn
form="cipherForm"
type="submit"
[hidden]="action === 'view'"
bitButton
class="primary"
appA11yTitle="{{ submitButtonText() }}"
>
{{ submitButtonText() }}
</button>
@if (!cipher.isDeleted && action === "view") {
<button
bitButton
buttonType="primary"
type="button"
(click)="edit()"
[appA11yTitle]="'edit' | i18n"
>
{{ "edit" | i18n }}
</button>
}
@if (cipher.isDeleted && cipher.permissions.restore) {
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
}
@if (hasFooterAction) {
<div class="tw-ml-auto tw-flex">
<button
type="button"
bitIconButton="bwi-trash"
buttonType="danger"
[label]="(cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n"
(click)="delete()"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
></button>
</div>
}
</div>

View File

@@ -0,0 +1,204 @@
import { CommonModule } from "@angular/common";
import {
Input,
Output,
EventEmitter,
Component,
OnInit,
ViewChild,
OnChanges,
SimpleChanges,
input,
} from "@angular/core";
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } 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 { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
ButtonComponent,
ButtonModule,
IconButtonModule,
DialogService,
ToastService,
} from "@bitwarden/components";
import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault";
// 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-vault-item-footer",
templateUrl: "item-footer.component.html",
imports: [ButtonModule, IconButtonModule, CommonModule, JslibModule],
})
export class ItemFooterComponent implements OnInit, OnChanges {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipher: CipherView = new CipherView();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() collectionId: string | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) action: string = "view";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() masterPasswordAlreadyPrompted: boolean = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onEdit = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onClone = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDelete = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onRestore = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onCancel = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onArchiveToggle = new EventEmitter<CipherView>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
readonly submitButtonText = input<string>(this.i18nService.t("save"));
activeUserId: UserId | null = null;
passwordReprompted: boolean = false;
protected showArchiveButton = false;
protected showUnarchiveButton = false;
constructor(
protected cipherService: CipherService,
protected dialogService: DialogService,
protected passwordRepromptService: PasswordRepromptService,
protected cipherAuthorizationService: CipherAuthorizationService,
protected accountService: AccountService,
protected toastService: ToastService,
protected i18nService: I18nService,
protected logService: LogService,
protected cipherArchiveService: CipherArchiveService,
protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
) {}
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
await this.checkArchiveState();
}
async ngOnChanges(changes: SimpleChanges) {
if (changes.cipher) {
await this.checkArchiveState();
}
}
async clone() {
if (this.cipher.login?.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
type: "info",
});
if (!confirmed) {
return false;
}
}
if (await this.promptPassword()) {
this.onClone.emit(this.cipher);
return true;
}
return false;
}
protected edit() {
this.onEdit.emit(this.cipher);
}
protected get hasFooterAction() {
return (
this.showArchiveButton ||
this.showUnarchiveButton ||
(this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view"))
);
}
cancel() {
this.onCancel.emit(this.cipher);
}
async delete() {
this.onDelete.emit(this.cipher);
}
async restore() {
this.onRestore.emit(this.cipher);
}
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, userId)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId);
}
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId);
}
protected async promptPassword() {
if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) {
return true;
}
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
}
protected async archive() {
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher);
this.onArchiveToggle.emit();
}
protected async unarchive() {
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher);
this.onArchiveToggle.emit();
}
private async checkArchiveState() {
const cipherCanBeArchived = !this.cipher.isDeleted;
const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((id) =>
combineLatest([
this.cipherArchiveService.userCanArchive$(id),
this.cipherArchiveService.hasArchiveFlagEnabled$,
]),
),
),
);
this.showArchiveButton =
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
// A user should always be able to unarchive an archived item
this.showUnarchiveButton =
hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived;
}
}

View File

@@ -0,0 +1,13 @@
<button
bitBadge
type="button"
[disabled]="disabled"
[style.color]="textColor"
[style.background-color]="color"
appA11yTitle="{{ organizationName }}"
routerLink
[queryParams]="{ organizationId: organizationIdLink }"
queryParamsHandling="merge"
>
{{ name | ellipsis: 13 }}
</button>

View File

@@ -0,0 +1,74 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnChanges } from "@angular/core";
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Unassigned } from "@bitwarden/common/admin-console/models/collections";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { BadgeModule } from "@bitwarden/components";
import { OrganizationId } from "@bitwarden/sdk-internal";
// 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-org-badge",
templateUrl: "organization-name-badge.component.html",
imports: [RouterModule, JslibModule, BadgeModule],
})
export class OrganizationNameBadgeComponent implements OnChanges {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() organizationId?: OrganizationId | string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() organizationName: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() disabled: boolean;
// Need a separate variable or we get weird behavior when used as part of cdk virtual scrolling
name: string;
color: string;
textColor: string;
isMe: boolean;
constructor(
private i18nService: I18nService,
private avatarService: AvatarService,
private tokenService: TokenService,
) {}
// ngOnChanges is required since this component might be reused as part of
// cdk virtual scrolling
async ngOnChanges() {
this.isMe = this.organizationName == null || this.organizationName === "";
if (this.isMe) {
this.name = this.i18nService.t("me");
this.color = await firstValueFrom(this.avatarService.avatarColor$);
if (this.color == null) {
const userId = await this.tokenService.getUserId();
if (userId != null) {
this.color = Utils.stringToColor(userId);
} else {
const userName =
(await this.tokenService.getName()) ?? (await this.tokenService.getEmail());
this.color = Utils.stringToColor(userName.toUpperCase());
}
}
} else {
this.name = this.organizationName;
this.color = Utils.stringToColor(this.organizationName.toUpperCase());
}
this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important";
}
get organizationIdLink() {
return this.organizationId ?? Unassigned;
}
}

View File

@@ -0,0 +1,9 @@
import { NgModule } from "@angular/core";
import { GetOrgNameFromIdPipe } from "@bitwarden/vault";
@NgModule({
declarations: [GetOrgNameFromIdPipe],
exports: [GetOrgNameFromIdPipe],
})
export class PipesModule {}

View File

@@ -0,0 +1,309 @@
<td bitCell [ngClass]="RowHeightClass" class="tw-truncate tw-flex tw-items-center tw-gap-2">
<app-vault-icon [cipher]="cipher()"></app-vault-icon>
<div class="tw-inline-flex tw-w-full">
<button
bitLink
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
[disabled]="disabled()"
[routerLink]="[]"
[queryParams]="{ itemId: cipher().id, action: clickAction() }"
queryParamsHandling="merge"
[replaceUrl]="true"
title="{{ 'editItemWithName' | i18n: cipher().name }}"
type="button"
appStopProp
aria-haspopup="true"
>
{{ cipher().name }}
</button>
@if (hasAttachments()) {
<i
class="bwi bwi-paperclip tw-ml-2 tw-leading-normal"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
@if (showFixOldAttachments()) {
<i
class="bwi bwi-exclamation-triangle tw-ml-2 tw-leading-normal tw-text-warning"
appStopProp
title="{{ 'attachmentsNeedFix' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachmentsNeedFix" | i18n }}</span>
}
}
</div>
<br />
<span class="tw-text-sm tw-text-muted" appStopProp>{{ subtitle() }}</span>
</td>
@if (showOwner()) {
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden lg:tw-table-cell">
<app-org-badge
[disabled]="disabled()"
[organizationId]="cipher().organizationId"
[organizationName]="cipher().organizationId | orgNameFromId: organizations()"
appStopProp
>
</app-org-badge>
</td>
}
@if (showGroups()) {
<td bitCell [ngClass]="RowHeightClass"></td>
}
@if (viewingOrgVault()) {
<td bitCell [ngClass]="RowHeightClass">
<p class="tw-mb-0 tw-text-muted">
{{ permissionText() }}
</p>
</td>
}
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
@if (decryptionFailure()) {
<button
[disabled]="disabled() || !canManageCollection()"
[bitMenuTriggerFor]="corruptedCipherOptions"
size="small"
bitIconButton="bwi-ellipsis-v"
type="button"
label="{{ 'options' | i18n }}"
appStopProp
></button>
<bit-menu #corruptedCipherOptions>
@if (canDeleteCipher()) {
<button bitMenuItem (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (isDeleted() ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
}
</bit-menu>
} @else {
@if (canLaunch()) {
<a [href]="launchUri()" target="_blank" rel="noreferrer">
<button
[disabled]="disabled()"
bitIconButton="bwi-external-link"
type="button"
appStopProp
label="{{ 'launch' | i18n }}"
></button>
</a>
}
<button
[bitMenuTriggerFor]="copyOptions"
[disabled]="disabled()"
bitIconButton="bwi-clone"
type="button"
appStopProp
label="{{ 'copyValue' | i18n }}"
></button>
<bit-menu #copyOptions>
@if (isLoginCipher()) {
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
@if (cipher().viewPassword) {
<button bitMenuItem type="button" appCopyField="password" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
}
<button bitMenuItem type="button" appCopyField="totp" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }}
</button>
}
@if (isCardCipher()) {
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNumber" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySecurityCode" | i18n }}
</button>
}
@if (isIdentityCipher()) {
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyEmail" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPhone" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyAddress" | i18n }}
</button>
}
@if (isSecureNoteCipher()) {
<button type="button" bitMenuItem appCopyField="secureNote" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNote" | i18n }}
</button>
}
</bit-menu>
<button
[bitMenuTriggerFor]="cipherOptions"
[disabled]="disabled()"
bitIconButton="bwi-ellipsis-v"
type="button"
appStopProp
label="{{ 'options' | i18n }}"
></button>
<bit-menu #cipherOptions>
@if (canLaunch()) {
<a bitMenuItem type="button" [href]="launchUri()" target="_blank" rel="noreferrer">
<i class="bwi bwi-fw bwi-external-link" aria-hidden="true"></i>
{{ "launch" | i18n }}
</a>
}
@if (isLoginCipher()) {
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
@if (cipher().viewPassword) {
<button bitMenuItem type="button" appCopyField="password" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPassword" | i18n }}
</button>
}
<button bitMenuItem type="button" appCopyField="totp" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyVerificationCode" | i18n }}
</button>
}
@if (isCardCipher()) {
<button type="button" bitMenuItem appCopyField="cardNumber" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNumber" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="securityCode" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySecurityCode" | i18n }}
</button>
}
@if (isIdentityCipher()) {
<button type="button" bitMenuItem appCopyField="username" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="email" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyEmail" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="phone" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyPhone" | i18n }}
</button>
<button type="button" bitMenuItem appCopyField="address" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyAddress" | i18n }}
</button>
}
@if (isSecureNoteCipher()) {
<button type="button" bitMenuItem appCopyField="secureNote" [cipher]="cipher()">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyNote" | i18n }}
</button>
}
@if (showMenuDivider()) {
<bit-menu-divider />
}
@if (!viewingOrgVault()) {
@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>
}
}
@if (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 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">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
@if (!userCanArchive()) {
<button bitMenuItem (click)="badge.promptForPremium($event)" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
<!-- Hide app-premium badge from accessibility tools as it results in a button within a button -->
<div slot="end" class="-tw-mt-0.5" aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</button>
}
}
@if (showUnArchiveButton()) {
<button bitMenuItem (click)="unarchive()" type="button">
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
}
@if (isDeleted() && canRestoreCipher()) {
<button bitMenuItem (click)="restore()" type="button">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
}
@if (canDeleteCipher()) {
<button bitMenuItem (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (isDeleted() ? "permanentlyDelete" : "delete") | i18n }}
</span>
</button>
}
</bit-menu>
}
</td>

View File

@@ -0,0 +1,308 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, HostListener, ViewChild, computed, input, output, inject } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuTriggerForDirective } from "@bitwarden/components";
import { VaultItemEvent } from "@bitwarden/vault";
// 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: "tr[appVaultCipherRow]",
templateUrl: "vault-cipher-row.component.html",
standalone: false,
})
export class VaultCipherRowComponent<C extends CipherViewLike> {
protected RowHeightClass = `tw-h-[75px]`;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective;
protected readonly disabled = input<boolean>();
protected readonly cipher = input<C>();
protected readonly showOwner = input<boolean>();
protected readonly showGroups = input<boolean>();
protected readonly showPremiumFeatures = input<boolean>();
protected readonly useEvents = input<boolean>();
protected readonly cloneable = input<boolean>();
protected readonly organizations = input<Organization[]>();
protected readonly viewingOrgVault = input<boolean>();
protected readonly canEditCipher = input<boolean>();
protected readonly canAssignCollections = input<boolean>();
protected readonly canManageCollection = input<boolean>();
/**
* uses new permission delete logic from PM-15493
*/
protected readonly canDeleteCipher = input<boolean>();
/**
* uses new permission restore logic from PM-15493
*/
protected readonly canRestoreCipher = input<boolean>();
/**
* user has archive permissions
*/
protected readonly userCanArchive = input<boolean>();
/** Archive feature is enabled */
readonly archiveEnabled = input.required<boolean>();
/**
* Enforce Org Data Ownership Policy Status
*/
protected readonly enforceOrgDataOwnershipPolicy = input<boolean>();
protected readonly onEvent = output<VaultItemEvent<C>>();
protected CipherType = CipherType;
private i18nService = inject(I18nService);
// Archive button will not show in Admin Console
protected readonly showArchiveButton = computed(() => {
if (!this.archiveEnabled() || this.viewingOrgVault()) {
return false;
}
return (
!CipherViewLikeUtils.isArchived(this.cipher()) &&
!CipherViewLikeUtils.isDeleted(this.cipher())
);
});
// If item is archived always show unarchive button, even if user is not premium
protected readonly showUnArchiveButton = computed(() => {
if (!this.archiveEnabled()) {
return false;
}
return CipherViewLikeUtils.isArchived(this.cipher());
});
protected readonly clickAction = computed(() => {
if (this.decryptionFailure()) {
return "showFailedToDecrypt";
}
return "view";
});
protected readonly showTotpCopyButton = computed(() => {
const login = CipherViewLikeUtils.getLogin(this.cipher());
const hasTotp = login?.totp ?? false;
return hasTotp && (this.cipher().organizationUseTotp || this.showPremiumFeatures());
});
protected readonly showFixOldAttachments = computed(() => {
return this.cipher().hasOldAttachments && this.cipher().organizationId == null;
});
protected readonly hasAttachments = computed(() => {
return CipherViewLikeUtils.hasAttachments(this.cipher());
});
// Do not show attachments button if:
// item is archived AND user is not premium user
protected readonly showAttachments = computed(() => {
if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) {
return false;
}
return this.canEditCipher() || this.hasAttachments();
});
protected readonly canLaunch = computed(() => {
return CipherViewLikeUtils.canLaunch(this.cipher());
});
protected readonly launchUri = computed(() => {
return CipherViewLikeUtils.getLaunchUri(this.cipher());
});
protected readonly subtitle = computed(() => {
return CipherViewLikeUtils.subtitle(this.cipher());
});
protected readonly isDeleted = computed(() => {
return CipherViewLikeUtils.isDeleted(this.cipher());
});
protected readonly decryptionFailure = computed(() => {
return CipherViewLikeUtils.decryptionFailure(this.cipher());
});
protected readonly showFavorite = computed(() => {
if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) {
return false;
}
return true;
});
// Do Not show Assign to Collections option if item is archived
protected readonly showAssignToCollections = computed(() => {
if (CipherViewLikeUtils.isArchived(this.cipher())) {
return false;
}
return (
this.organizations()?.length &&
this.canAssignCollections() &&
!CipherViewLikeUtils.isDeleted(this.cipher())
);
});
// Do NOT show clone option if:
// item is archived AND user is not premium user
// item is archived AND enforce org data ownership policy is on
protected readonly showClone = computed(() => {
if (
CipherViewLikeUtils.isArchived(this.cipher()) &&
(!this.userCanArchive() || this.enforceOrgDataOwnershipPolicy())
) {
return false;
}
return this.cloneable() && !CipherViewLikeUtils.isDeleted(this.cipher());
});
protected readonly showEventLogs = computed(() => {
return this.useEvents() && this.cipher().organizationId;
});
protected readonly isLoginCipher = computed(() => {
return (
CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Login &&
!CipherViewLikeUtils.isDeleted(this.cipher()) &&
!CipherViewLikeUtils.isArchived(this.cipher())
);
});
protected readonly permissionText = computed(() => {
if (!this.cipher().organizationId || this.cipher().collectionIds.length === 0) {
return this.i18nService.t("manageCollection");
}
return this.i18nService.t("noAccess");
});
protected readonly hasVisibleLoginOptions = computed(() => {
return (
this.isLoginCipher() &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher(), "username") ||
(this.cipher().viewPassword &&
CipherViewLikeUtils.hasCopyableValue(this.cipher(), "password")) ||
this.showTotpCopyButton() ||
this.canLaunch())
);
});
protected readonly isCardCipher = computed(() => {
return CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Card && !this.isDeleted();
});
protected readonly hasVisibleCardOptions = computed(() => {
return (
this.isCardCipher() &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher(), "cardNumber") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher(), "securityCode"))
);
});
protected readonly isIdentityCipher = computed(() => {
if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) {
return false;
}
return (
CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Identity && !this.isDeleted()
);
});
protected readonly hasVisibleIdentityOptions = computed(() => {
return (
this.isIdentityCipher() &&
(CipherViewLikeUtils.hasCopyableValue(this.cipher(), "address") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher(), "email") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher(), "username") ||
CipherViewLikeUtils.hasCopyableValue(this.cipher(), "phone"))
);
});
protected readonly isSecureNoteCipher = computed(() => {
return (
CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.SecureNote &&
!(this.isDeleted() && this.canRestoreCipher())
);
});
protected readonly hasVisibleSecureNoteOptions = computed(() => {
return (
this.isSecureNoteCipher() && CipherViewLikeUtils.hasCopyableValue(this.cipher(), "secureNote")
);
});
protected readonly showMenuDivider = computed(() => {
return (
this.hasVisibleLoginOptions() ||
this.hasVisibleCardOptions() ||
this.hasVisibleIdentityOptions() ||
this.hasVisibleSecureNoteOptions()
);
});
protected clone() {
this.onEvent.emit({ type: "clone", item: this.cipher() });
}
protected events() {
this.onEvent.emit({ type: "viewEvents", item: this.cipher() });
}
protected archive() {
this.onEvent.emit({ type: "archive", items: [this.cipher()] });
}
protected unarchive() {
this.onEvent.emit({ type: "unarchive", items: [this.cipher()] });
}
protected restore() {
this.onEvent.emit({ type: "restore", items: [this.cipher()] });
}
protected deleteCipher() {
this.onEvent.emit({ type: "delete", items: [{ cipher: this.cipher() }] });
}
protected attachments() {
this.onEvent.emit({ type: "viewAttachments", item: this.cipher() });
}
protected assignToCollections() {
this.onEvent.emit({ type: "assignToCollections", items: [this.cipher()] });
}
protected toggleFavorite() {
this.onEvent.emit({
type: "toggleFavorite",
item: this.cipher(),
});
}
protected editCipher() {
this.onEvent.emit({ type: "editCipher", item: this.cipher() });
}
@HostListener("contextmenu", ["$event"])
protected onRightClick(event: MouseEvent) {
if (event.shiftKey && event.ctrlKey) {
return;
}
if (!this.disabled() && this.menuTrigger) {
this.menuTrigger.toggleMenuOnRightClick(event);
}
}
}

View File

@@ -0,0 +1,38 @@
<td bitCell [ngClass]="RowHeightClass">
<div class="tw-flex tw-gap-3">
<div aria-hidden="true">
<i
[class]="
'bwi bwi-fw bwi-lg ' +
(collection().type === DefaultCollectionType ? 'bwi-user' : 'bwi-collection-shared')
"
></i>
</div>
<button
bitLink
[disabled]="disabled()"
type="button"
class="tw-flex tw-text-start tw-leading-snug tw-truncate"
linkType="secondary"
title="{{ 'viewCollectionWithName' | i18n: collection().name }}"
[routerLink]="[]"
[queryParams]="{ collectionId: collection().id }"
queryParamsHandling="merge"
appStopProp
>
<span class="tw-truncate tw-mr-1">{{ collection().name }}</span>
</button>
</div>
</td>
@if (showOwner()) {
<td bitCell [ngClass]="RowHeightClass" class="tw-hidden lg:tw-table-cell">
<app-org-badge
[disabled]="disabled()"
[organizationId]="collection().organizationId"
[organizationName]="collection().organizationId | orgNameFromId: organizations()"
appStopProp
>
</app-org-badge>
</td>
}
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"></td>

View File

@@ -0,0 +1,26 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, input } from "@angular/core";
import {
CollectionView,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
// 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: "tr[appVaultCollectionRow]",
templateUrl: "vault-collection-row.component.html",
standalone: false,
})
export class VaultCollectionRowComponent {
protected RowHeightClass = `tw-h-[75px]`;
protected DefaultCollectionType = CollectionTypes.DefaultUserCollection;
protected readonly disabled = input<boolean>();
protected readonly collection = input<CollectionView>();
protected readonly showOwner = input<boolean>();
protected readonly organizations = input<Organization[]>();
}

View File

@@ -0,0 +1,46 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
BadgeModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
ScrollLayoutDirective,
TableModule,
} from "@bitwarden/components";
import { CopyCipherFieldDirective } from "@bitwarden/vault";
import { OrganizationNameBadgeComponent } from "../organization-badge/organization-name-badge.component";
import { PipesModule } from "../pipes/pipes.module";
import { VaultCipherRowComponent } from "./vault-cipher-row.component";
import { VaultCollectionRowComponent } from "./vault-collection-row.component";
@NgModule({
imports: [
CommonModule,
RouterModule,
ScrollingModule,
JslibModule,
TableModule,
MenuModule,
IconButtonModule,
IconModule,
LinkModule,
BadgeModule,
CopyCipherFieldDirective,
ScrollLayoutDirective,
PremiumBadgeComponent,
OrganizationNameBadgeComponent,
PipesModule,
],
declarations: [VaultCipherRowComponent, VaultCollectionRowComponent],
exports: [VaultCipherRowComponent, VaultCollectionRowComponent],
})
export class VaultItemsModule {}

View File

@@ -0,0 +1,93 @@
<cdk-virtual-scroll-viewport [itemSize]="RowHeight" bitScrollLayout class="tw-pb-8">
<div class="tw-px-8 tw-pt-6">
<app-header [title]="'vault' | i18n">
<vault-new-cipher-menu
[canCreateCipher]="true"
[canCreateFolder]="true"
[canCreateSshKey]="true"
(cipherAdded)="addCipher($event)"
(folderAdded)="addFolder()"
/>
</app-header>
</div>
<div class="tw-mb-[16px]">
<app-search />
</div>
<bit-table [dataSource]="dataSource" layout="fixed">
<ng-container header>
<tr>
<!-- Individual or Organization vault -->
<th
bitCell
bitSortable="name"
[fn]="sortByName"
[class]="showExtraColumn ? 'tw-w-3/6' : 'tw-w-full'"
>
{{ "name" | i18n }}
</th>
@if (showOwner()) {
<th
bitCell
bitSortable="owner"
[fn]="sortByOwner"
class="tw-hidden tw-w-2/6 lg:tw-table-cell"
>
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-hidden tw-w-1/6 lg:tw-table-cell tw-text-right tw-font-medium">
{{ "options" | i18n }}
</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<ng-container *cdkVirtualFor="let item of rows$; templateCacheSize: 0">
@if (item.collection) {
<tr
bitRow
appVaultCollectionRow
alignContent="middle"
[disabled]="disabled()"
[collection]="item.collection"
[showOwner]="showOwner()"
[organizations]="allOrganizations()"
></tr>
}
<!--
addAccessStatus check here so ciphers do not show if user
has filtered for collections with addAccess
-->
@if (
item.cipher && (!addAccessToggle() || (addAccessToggle() && addAccessStatus() !== 1))
) {
<tr
bitRow
appVaultCipherRow
alignContent="middle"
[disabled]="disabled()"
[cipher]="item.cipher"
[showOwner]="showOwner()"
[showPremiumFeatures]="showPremiumFeatures()"
[useEvents]="useEvents()"
[viewingOrgVault]="viewingOrgVault()"
[cloneable]="canClone$(item) | async"
[organizations]="allOrganizations()"
[canEditCipher]="canEditCipher(item.cipher)"
[canAssignCollections]="canAssignCollections(item.cipher)"
[canManageCollection]="canManageCollection(item.cipher)"
[canDeleteCipher]="
cipherAuthorizationService.canDeleteCipher$(item.cipher, showAdminActions()) | async
"
[canRestoreCipher]="
cipherAuthorizationService.canRestoreCipher$(item.cipher, showAdminActions()) | async
"
(onEvent)="event($event)"
[userCanArchive]="userCanArchive()"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy()"
[archiveEnabled]="archiveFeatureEnabled$ | async"
></tr>
}
</ng-container>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>

View File

@@ -0,0 +1,309 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { SelectionModel } from "@angular/cdk/collections";
import { ScrollingModule } from "@angular/cdk/scrolling";
import { AsyncPipe } from "@angular/common";
import { Component, input, output, effect, inject, computed } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Observable, of, switchMap } from "rxjs";
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
RestrictedCipherType,
RestrictedItemTypesService,
} from "@bitwarden/common/vault/services/restricted-item-types.service";
import {
CipherViewLike,
CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import {
SortDirection,
TableDataSource,
TableModule,
MenuModule,
ButtonModule,
IconButtonModule,
} from "@bitwarden/components";
import { OrganizationId } from "@bitwarden/sdk-internal";
import { I18nPipe } from "@bitwarden/ui-common";
import { NewCipherMenuComponent, VaultItem, VaultItemEvent } from "@bitwarden/vault";
import { DesktopHeaderComponent } from "../../../app/layout/header/desktop-header.component";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { SearchComponent } from "../../../app/layout/search/search.component";
import { VaultItemsModule } from "./vault-items/vault-items.module";
// Fixed manual row height required due to how cdk-virtual-scroll works
export const RowHeight = 75;
export const RowHeightClass = `tw-h-[75px]`;
// 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-vault-list",
templateUrl: "vault-list.component.html",
imports: [
ScrollingModule,
TableModule,
I18nPipe,
AsyncPipe,
MenuModule,
ButtonModule,
IconButtonModule,
VaultItemsModule,
SearchComponent,
DesktopHeaderComponent,
NewCipherMenuComponent,
],
})
export class VaultListComponent<C extends CipherViewLike> {
protected RowHeight = RowHeight;
protected readonly disabled = input<boolean>();
protected readonly showOwner = input<boolean>();
protected readonly useEvents = input<boolean>();
protected readonly showPremiumFeatures = input<boolean>();
// Encompasses functionality only available from the organization vault context
protected readonly showAdminActions = input<boolean>(false);
protected readonly allOrganizations = input<Organization[]>([]);
protected readonly allCollections = input<CollectionView[]>([]);
protected readonly showPermissionsColumn = input<boolean>(false);
protected readonly viewingOrgVault = input<boolean>();
protected readonly addAccessStatus = input<number>();
protected readonly addAccessToggle = input<boolean>();
protected readonly activeCollection = input<CollectionView | undefined>();
protected readonly userCanArchive = input<boolean>();
protected readonly enforceOrgDataOwnershipPolicy = input<boolean>();
protected readonly ciphers = input<C[]>([]);
protected readonly collections = input<CollectionView[]>([]);
protected onEvent = output<VaultItemEvent<C>>();
protected onAddCipher = output<CipherType>();
protected onAddFolder = output<void>();
protected cipherAuthorizationService = inject(CipherAuthorizationService);
protected restrictedItemTypesService = inject(RestrictedItemTypesService);
protected cipherArchiveService = inject(CipherArchiveService);
private searchBarService = inject(SearchBarService);
private i18nService = inject(I18nService);
protected dataSource = new TableDataSource<VaultItem<C>>();
protected selection = new SelectionModel<VaultItem<C>>(true, [], true);
private restrictedTypes: RestrictedCipherType[] = [];
protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$;
constructor() {
// Enable the search bar
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
this.restrictedItemTypesService.restricted$.pipe(takeUntilDestroyed()).subscribe((types) => {
this.restrictedTypes = types;
this.refreshItems();
});
// Refresh items when collections or ciphers change
effect(() => {
this.collections();
this.ciphers();
this.refreshItems();
});
}
protected readonly showExtraColumn = computed(() => this.showOwner());
protected event(event: VaultItemEvent<C>) {
this.onEvent.emit(event);
}
protected addCipher(type: CipherType) {
this.onAddCipher.emit(type);
}
protected addFolder() {
this.onAddFolder.emit();
}
protected canClone$(vaultItem: VaultItem<C>): Observable<boolean> {
return this.restrictedItemTypesService.restricted$.pipe(
switchMap((restrictedTypes) => {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = restrictedTypes.some(
(rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher),
);
if (isItemRestricted) {
return of(false);
}
return this.cipherAuthorizationService.canCloneCipher$(
vaultItem.cipher,
this.showAdminActions(),
);
}),
);
}
protected canEditCipher(cipher: C) {
if (cipher.organizationId == null) {
return true;
}
const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId);
return (organization.canEditAllCiphers && this.viewingOrgVault()) || cipher.edit;
}
protected canAssignCollections(cipher: C) {
const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId);
const editableCollections = this.allCollections().filter((c) => !c.readOnly);
return (
(organization?.canEditAllCiphers && this.viewingOrgVault()) ||
(CipherViewLikeUtils.canAssignToCollections(cipher) && editableCollections.length > 0)
);
}
protected canManageCollection(cipher: C) {
// If the cipher is not part of an organization (personal item), user can manage it
if (cipher.organizationId == null) {
return true;
}
// Check for admin access in AC vault
if (this.showAdminActions()) {
const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId);
// If the user is an admin, they can delete an unassigned cipher
if (cipher.collectionIds.length === 0) {
return organization?.canEditUnmanagedCollections === true;
}
if (
organization?.permissions.editAnyCollection ||
(organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin)
) {
return true;
}
}
if (this.activeCollection()) {
return this.activeCollection().manage === true;
}
return this.allCollections()
.filter((c) => cipher.collectionIds.includes(c.id as any))
.some((collection) => collection.manage);
}
private refreshItems() {
const collections: VaultItem<C>[] =
this.collections()?.map((collection) => ({ collection })) || [];
const ciphers: VaultItem<C>[] = this.ciphers()
.filter(
(cipher) =>
!this.restrictedItemTypesService.isCipherRestricted(cipher, this.restrictedTypes),
)
.map((cipher) => ({ cipher }));
const items: VaultItem<C>[] = [].concat(collections).concat(ciphers);
this.dataSource.data = items;
}
protected assignToCollections() {
this.event({
type: "assignToCollections",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected showAssignToCollections(): boolean {
// When the user doesn't belong to an organization, hide assign to collections
if (this.allOrganizations().length === 0) {
return false;
}
if (this.selection.selected.length === 0) {
return false;
}
const hasPersonalItems = this.hasPersonalItems();
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
const hasEditableCollections = this.allCollections().some((collection) => {
return !collection.readOnly;
});
// Return false if items are from different organizations
if (uniqueCipherOrgIds.size > 1) {
return false;
}
// If all selected items are personal, return based on personal items
if (uniqueCipherOrgIds.size === 0 && hasEditableCollections) {
return hasPersonalItems;
}
const [orgId] = uniqueCipherOrgIds;
const organization = this.allOrganizations().find((o) => o.id === orgId);
const canEditOrManageAllCiphers = organization?.canEditAllCiphers && this.viewingOrgVault();
const collectionNotSelected =
this.selection.selected.filter((item) => item.collection).length === 0;
return (
(canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) &&
collectionNotSelected &&
hasEditableCollections
);
}
/**
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
*/
protected sortByName = (a: VaultItem<C>, b: VaultItem<C>, direction: SortDirection) => {
return this.compareNames(a, b);
};
protected sortByOwner = (a: VaultItem<C>, b: VaultItem<C>, direction: SortDirection) => {
const getOwnerName = (item: VaultItem<C>): string => {
if (item.cipher) {
return (item.cipher.organizationId as string) || "";
} else if (item.collection) {
return (item.collection.organizationId as string) || "";
}
return "";
};
const ownerA = getOwnerName(a);
const ownerB = getOwnerName(b);
return ownerA.localeCompare(ownerB);
};
private compareNames(a: VaultItem<C>, b: VaultItem<C>): number {
const getName = (item: VaultItem<C>) => item.collection?.name || item.cipher?.name;
return getName(a)?.localeCompare(getName(b)) ?? -1;
}
private hasPersonalItems(): boolean {
return this.selection.selected.some(({ cipher }) => !cipher?.organizationId);
}
private allCiphersHaveEditAccess(): boolean {
return this.selection.selected
.filter(({ cipher }) => cipher)
.every(({ cipher }) => cipher?.edit && cipher?.viewPassword);
}
private getUniqueOrganizationIds(): Set<string | [] | OrganizationId> {
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
}
}

View File

@@ -1,70 +1,93 @@
<div id="vault" class="vault vault-v2" attr.aria-hidden="{{ showingModal }}">
<app-vault-items-v2
<div id="vault" class="vault vault-v2">
<app-vault-list
id="items"
class="items"
[activeCipherId]="cipherId"
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
#vaultItems
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="allOrganizations"
[disabled]="refreshing"
[showOwner]="true"
[showPremiumFeatures]="canAccessPremium"
[useEvents]="false"
[showAdminActions]="false"
[userCanArchive]="userCanArchive$ | async"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy$ | async"
(onEvent)="onVaultItemsEvent($event)"
(onAddCipher)="addCipher($event)"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
(onAddFolder)="addFolder()"
/>
@if (!!action()) {
<div class="details">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher()"
[action]="action()"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher($event)"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher($event)"
(onCancel)="cancelCipher()"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
@if (action() === "view") {
<app-cipher-view [cipher]="cipher()" [collections]="collections"> </app-cipher-view>
}
@if (action() === "add" || action() === "edit" || action() === "clone") {
<vault-cipher-form
#vaultForm
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
</div>
</div>
<div class="tw-bg-bg-secondary tw-px-8 tw-pt-6">
<app-header [title]="headerTitleKey() | i18n">
<button
type="button"
bitIconButton="bwi-close"
[label]="'close' | i18n"
[disabled]="formDisabled"
(click)="cancelCipher()"
></button>
</app-header>
</div>
</div>
}
@if (action() !== "add" && action() !== "edit" && action() !== "view" && action() !== "clone") {
<div id="logo" class="logo">
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
}
</div>
<ng-template #folderAddEdit></ng-template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { VaultItemEvent as BaseVaultItemEvent } from "@bitwarden/vault";
import { CollectionPermission } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector";
import { VaultItem } from "./vault-item";
// Extend base events with web-specific events
export type VaultItemEvent<C extends CipherViewLike> =
| { type: "viewAttachments"; item: C }
| BaseVaultItemEvent<C>
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| {
type: "viewCollectionAccess";
@@ -13,15 +13,4 @@ export type VaultItemEvent<C extends CipherViewLike> =
readonly: boolean;
initialPermission?: CollectionPermission;
}
| { type: "viewEvents"; item: C }
| { type: "editCollection"; item: CollectionView; readonly: boolean }
| { type: "clone"; item: C }
| { type: "restore"; items: C[] }
| { type: "delete"; items: VaultItem<C>[] }
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] }
| { type: "toggleFavorite"; item: C }
| { type: "editCipher"; item: C };
| { type: "editCollection"; item: CollectionView; readonly: boolean };

View File

@@ -11,9 +11,8 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuModule, TableModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { RoutedVaultFilterService, RoutedVaultFilterModel } from "@bitwarden/vault";
import { RoutedVaultFilterService, RoutedVaultFilterModel, VaultItem } from "@bitwarden/vault";
import { VaultItem } from "./vault-item";
import { VaultItemsComponent } from "./vault-items.component";
describe("VaultItemsComponent", () => {

View File

@@ -31,7 +31,7 @@ import {
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { SortDirection, TableDataSource } from "@bitwarden/components";
import { OrganizationId } from "@bitwarden/sdk-internal";
import { RoutedVaultFilterService } from "@bitwarden/vault";
import { RoutedVaultFilterService, VaultItem } from "@bitwarden/vault";
import { GroupView } from "../../../admin-console/organizations/core";
@@ -39,7 +39,6 @@ import {
CollectionPermission,
convertToPermission,
} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event";
// Fixed manual row height required due to how cdk-virtual-scroll works

View File

@@ -1,7 +1,8 @@
import { NgModule } from "@angular/core";
import { GetOrgNameFromIdPipe } from "@bitwarden/vault";
import { GetGroupNameFromIdPipe } from "./get-group-name.pipe";
import { GetOrgNameFromIdPipe } from "./get-organization-name.pipe";
@NgModule({
declarations: [GetOrgNameFromIdPipe, GetGroupNameFromIdPipe],

View File

@@ -112,6 +112,7 @@ import {
OrganizationFilter,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
VaultItem,
} from "@bitwarden/vault";
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
@@ -134,7 +135,6 @@ import {
VaultItemDialogMode,
VaultItemDialogResult,
} from "../components/vault-item-dialog/vault-item-dialog.component";
import { VaultItem } from "../components/vault-items/vault-item";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsComponent } from "../components/vault-items/vault-items.component";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";

View File

@@ -0,0 +1,16 @@
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { VaultItem } from "@bitwarden/vault";
export type VaultItemEvent<C extends CipherViewLike> =
| { type: "viewAttachments"; item: C }
| { type: "viewEvents"; item: C }
| { type: "clone"; item: C }
| { type: "restore"; items: C[] }
| { type: "delete"; items: VaultItem<C>[] }
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] }
| { type: "toggleFavorite"; item: C }
| { type: "editCipher"; item: C };

View File

@@ -12,6 +12,7 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi
export { OrgIconDirective } from "./components/org-icon.directive";
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
export { DarkImageSourceDirective } from "./components/dark-image-source.directive";
export { GetOrgNameFromIdPipe } from "./pipes/get-organization-name.pipe";
export * from "./cipher-view";
export * from "./cipher-form";
@@ -30,6 +31,8 @@ export * from "./components/carousel";
export * from "./components/new-cipher-menu/new-cipher-menu.component";
export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component";
export * from "./components/vault-items-transfer";
export { VaultItem } from "./components/vault-item";
export { VaultItemEvent } from "./components/vault-item-event";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";