diff --git a/.storybook/main.js b/.storybook/main.js index 7c0d2d97a1..83fe7fef3d 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -6,6 +6,8 @@ module.exports = { "../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)", "../apps/web/src/**/*.stories.mdx", "../apps/web/src/**/*.stories.@(js|jsx|ts|tsx)", + "../bitwarden_license/bit-web/src/**/*.stories.mdx", + "../bitwarden_license/bit-web/src/**/*.stories.@(js|jsx|ts|tsx)", ], addons: [ "@storybook/addon-links", diff --git a/apps/desktop/src/app/accounts/delete-account.component.ts b/apps/desktop/src/app/accounts/delete-account.component.ts index 74e79b2290..6ee941df83 100644 --- a/apps/desktop/src/app/accounts/delete-account.component.ts +++ b/apps/desktop/src/app/accounts/delete-account.component.ts @@ -5,8 +5,7 @@ import { AccountApiService } from "@bitwarden/common/abstractions/account/accoun import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - -import { Verification } from "../../../../../libs/common/src/types/verification"; +import { Verification } from "@bitwarden/common/types/verification"; @Component({ selector: "app-delete-account", diff --git a/apps/web/config/development.json b/apps/web/config/development.json index f460a1659a..15a09d724f 100644 --- a/apps/web/config/development.json +++ b/apps/web/config/development.json @@ -11,6 +11,7 @@ }, "flags": { "showTrial": true, + "secretsManager": true, "showPasswordless": true } } diff --git a/apps/web/config/qa.json b/apps/web/config/qa.json index a0d1b0e88c..00dcc66d62 100644 --- a/apps/web/config/qa.json +++ b/apps/web/config/qa.json @@ -11,6 +11,7 @@ }, "flags": { "showTrial": true, + "secretsManager": false, "showPasswordless": true } } diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index c217b9b12e..7df440ea50 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -13,10 +13,12 @@ import { BadgeModule, ButtonModule, CalloutModule, + DialogModule, FormFieldModule, IconButtonModule, IconModule, MenuModule, + NavigationModule, TableModule, TabsModule, } from "@bitwarden/components"; @@ -36,46 +38,55 @@ import "./locales"; CommonModule, DragDropModule, FormsModule, - InfiniteScrollModule, - JslibModule, ReactiveFormsModule, + InfiniteScrollModule, RouterModule, + ToastrModule, + JslibModule, + + // Component library + AsyncActionsModule, + AvatarModule, BadgeModule, ButtonModule, CalloutModule, - ToastrModule, - BadgeModule, - ButtonModule, - MenuModule, + DialogModule, FormFieldModule, - IconModule, - TabsModule, - TableModule, - AvatarModule, IconButtonModule, + IconModule, + MenuModule, + NavigationModule, + TableModule, + TabsModule, + + // Web specific ], exports: [ CommonModule, - AsyncActionsModule, DragDropModule, FormsModule, - InfiniteScrollModule, - JslibModule, ReactiveFormsModule, + InfiniteScrollModule, RouterModule, + ToastrModule, + JslibModule, + + // Component library + AsyncActionsModule, + AvatarModule, BadgeModule, ButtonModule, CalloutModule, - ToastrModule, - BadgeModule, - ButtonModule, - MenuModule, + DialogModule, FormFieldModule, - IconModule, - TabsModule, - TableModule, - AvatarModule, IconButtonModule, + IconModule, + MenuModule, + NavigationModule, + TableModule, + TabsModule, + + // Web specific ], providers: [DatePipe], bootstrap: [], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 94cb80076d..82c450cadd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5487,6 +5487,258 @@ "multiSelectClearAll": { "message": "Clear all" }, + "projects":{ + "message": "Projects" + }, + "lastEdited":{ + "message": "Last Edited" + }, + "editSecret":{ + "message": "Edit Secret" + }, + "addSecret":{ + "message": "Add Secret" + }, + "copySecretName":{ + "message": "Copy Secret Name" + }, + "copySecretValue":{ + "message": "Copy Secret Value" + }, + "deleteSecret":{ + "message": "Delete Secret" + }, + "deleteSecrets":{ + "message": "Delete Secrets" + }, + "project":{ + "message": "Project" + }, + "editProject":{ + "message": "Edit Project" + }, + "viewProject":{ + "message": "View Project" + }, + "deleteProject":{ + "message": "Delete Project" + }, + "deleteProjects":{ + "message": "Delete Projects" + }, + "secret":{ + "message": "Secret" + }, + "serviceAccount":{ + "message": "Service Account" + }, + "serviceAccounts":{ + "message": "Service Accounts" + }, + "new":{ + "message": "New" + }, + "secrets":{ + "message":"Secrets" + }, + "nameValuePair":{ + "message":"Name/Value Pair" + }, + "secretEdited":{ + "message":"Secret edited" + }, + "secretCreated":{ + "message":"Secret created" + }, + "newSecret":{ + "message":"New Secret" + }, + "newServiceAccount":{ + "message":"New Service Account" + }, + "importSecrets":{ + "message":"Import Secrets" + }, + "secretsNoItemsTitle":{ + "message":"No secrets to show" + }, + "secretsNoItemsMessage":{ + "message": "To get started, add a new secret or import secrets." + }, + "serviceAccountsNoItemsTitle":{ + "message":"Nothing to show yet" + }, + "serviceAccountsNoItemsMessage":{ + "message": "Create a new Service Account to get started automating secret access." + }, + "searchSecrets":{ + "message":"Search Secrets" + }, + "deleteServiceAccounts":{ + "message":"Delete Service Accounts" + }, + "deleteServiceAccount":{ + "message":"Delete Service Account" + }, + "viewServiceAccount":{ + "message":"View Service Account" + }, + "searchServiceAccounts":{ + "message":"Search Service Accounts" + }, + "addProject":{ + "message": "Add Project" + }, + "projectEdited":{ + "message":"Project edited" + }, + "projectSaved":{ + "message":"Project saved" + }, + "projectCreated":{ + "message":"Project created" + }, + "projectName":{ + "message":"Project Name" + }, + "newProject":{ + "message":"New Project" + }, + "softDeleteSecretWarning":{ + "message":"Deleting secrets can affect existing integrations." + }, + "softDeletesSuccessToast":{ + "message":"Secrets sent to trash" + }, + "serviceAccountCreated":{ + "message":"Service Account Created" + }, + "smAccess":{ + "message":"Access" + }, + "projectCommaSecret":{ + "message":"Project, Secret" + }, + "serviceAccountName":{ + "message": "Service account name" + }, + "newSaSelectAccess":{ + "message": "Type or Select Projects or Secrets" + }, + "newSaTypeToFilter":{ + "message": "Type to Filter" + }, + "deleteProjectsToast":{ + "message": "Projects deleted" + }, + "deleteProjectToast":{ + "message": "The project and all associated secrets have been deleted" + }, + "deleteProjectDialogMessage": { + "message": "Deleting project $PROJECT$ is permanent and irreversible.", + "placeholders": { + "project": { + "content": "$1", + "example": "project name" + } + } + }, + "deleteProjectInputLabel": { + "message": "Type \"$CONFIRM$\" to continue", + "placeholders": { + "confirm": { + "content": "$1", + "example": "Delete 3 Projects" + } + } + }, + "deleteProjectConfirmMessage":{ + "message": "Delete $PROJECT$", + "placeholders": { + "project": { + "content": "$1", + "example": "project name" + } + } + }, + "deleteProjectsConfirmMessage":{ + "message": "Delete $COUNT$ Projects", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, + "deleteProjectsDialogMessage":{ + "message": "Deleting projects is permanent and irreversible." + }, + "projectsNoItemsTitle":{ + "message": "No projects to display" + }, + "projectsNoItemsMessage":{ + "message": "Add a new project to get started organizing secrets." + }, + "smConfirmationRequired":{ + "message": "Confirmation required" + }, + "bulkDeleteProjectsErrorMessage":{ + "message": "The following projects could not be deleted:" + }, + "softDeleteSuccessToast":{ + "message":"Secret sent to trash" + }, + "searchProjects":{ + "message":"Search Projects" + }, + "accessTokens": { + "message": "Access tokens" + }, + "createAccessToken": { + "message": "Create access token" + }, + "expires": { + "message": "Expires" + }, + "canRead": { + "message": "Can Read" + }, + "accessTokensNoItemsTitle": { + "message": "No access tokens to show" + }, + "accessTokensNoItemsDesc": { + "message": "To get started, create an access token" + }, + "downloadAccessToken": { + "message": "Download or copy before closing." + }, + "expiresOnAccessToken": { + "message": "Expires on:" + }, + "accessTokenCallOutTitle": { + "message": "Access tokens are not stored and cannot be retrieved" + }, + "copyToken": { + "message": "Copy token" + }, + "accessToken": { + "message": "Access token" + }, + "accessTokenExpirationRequired": { + "message": "Expiration date required" + }, + "accessTokenCreatedAndCopied": { + "message": "Access token created and copied to clipboard" + }, + "accessTokenPermissionsBetaNotification": { + "message": "Permissions management is unavailable for beta." + }, + "revokeAccessToken": { + "message": "Revoke Access Token" + }, + "submenu": { + "message": "Submenu" + }, "from": { "message": "From" }, diff --git a/apps/web/src/utils/flags.ts b/apps/web/src/utils/flags.ts index 195bc8e5f5..03e4b2a9c9 100644 --- a/apps/web/src/utils/flags.ts +++ b/apps/web/src/utils/flags.ts @@ -10,6 +10,7 @@ import { /* eslint-disable-next-line @typescript-eslint/ban-types */ export type Flags = { showTrial?: boolean; + secretsManager?: boolean; showPasswordless?: boolean; } & SharedFlags; diff --git a/bitwarden_license/bit-web/src/app/app-routing.module.ts b/bitwarden_license/bit-web/src/app/app-routing.module.ts index c61367ccdc..aea521f3b0 100644 --- a/bitwarden_license/bit-web/src/app/app-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/app-routing.module.ts @@ -10,7 +10,8 @@ const routes: Routes = [ }, { path: "sm", - loadChildren: async () => (await import("./sm/sm.module")).SecretsManagerModule, + loadChildren: async () => + (await import("./secrets-manager/secrets-manager.module")).SecretsManagerModule, }, ]; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.html new file mode 100644 index 0000000000..69489b452b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.html @@ -0,0 +1,33 @@ + + + {{ data.title | i18n }} + + {{ data.details.length }} + {{ data.subTitle | i18n }} + + + +
+ {{ data.message | i18n }} + + + + {{ data.columnTitle | i18n }} + {{ "error" | i18n }} + + + + + {{ detail.name }} + {{ detail.errorMessage }} + + + +
+ +
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.ts new file mode 100644 index 0000000000..08525646f8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/dialogs/bulk-status-dialog.component.ts @@ -0,0 +1,40 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +export interface BulkStatusDetails { + title: string; + subTitle: string; + columnTitle: string; + message: string; + details: BulkOperationStatus[]; +} + +export class BulkOperationStatus { + id: string; + name: string; + errorMessage?: string; +} + +@Component({ + selector: "sm-bulk-status-dialog", + templateUrl: "./bulk-status-dialog.component.html", +}) +export class BulkStatusDialogComponent implements OnInit { + constructor(public dialogRef: DialogRef, @Inject(DIALOG_DATA) public data: BulkStatusDetails) {} + + ngOnInit(): void { + // TODO remove null checks once strictNullChecks in TypeScript is turned on. + if ( + !this.data.title || + !this.data.subTitle || + !this.data.columnTitle || + !this.data.message || + !(this.data.details?.length >= 1) + ) { + this.dialogRef.close(); + throw new Error( + "The bulk status dialog was not called with the appropriate operation values." + ); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.html new file mode 100644 index 0000000000..147e35c9ca --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.html @@ -0,0 +1 @@ + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.ts new file mode 100644 index 0000000000..dd6112be99 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/filter.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "sm-filter", + templateUrl: "./filter.component.html", +}) +export class FilterComponent {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.html new file mode 100644 index 0000000000..8f44da97aa --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.html @@ -0,0 +1,56 @@ +
+

{{ routeData.title | i18n }}

+
+ +
+ + + + + + +
+
+ +
+ {{ "loggedInAs" | i18n }} + + {{ account.name }} + +
+
+ + + + + + {{ "accountSettings" | i18n }} + + + + {{ "getHelp" | i18n }} + + + + {{ "getApps" | i18n }} + + + + + + +
+
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.ts new file mode 100644 index 0000000000..8ad4e6a68e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/header.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, map, Observable } from "rxjs"; + +import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { AccountProfile } from "@bitwarden/common/models/domain/account"; + +@Component({ + selector: "sm-header", + templateUrl: "./header.component.html", +}) +export class HeaderComponent { + @Input() title: string; + @Input() searchTitle: string; + + protected routeData$: Observable<{ title: string; searchTitle: string }>; + protected account$: Observable; + + constructor(private route: ActivatedRoute, private stateService: StateService) { + this.routeData$ = this.route.data.pipe( + map((params) => { + return { + title: params.title, + searchTitle: params.searchTitle, + }; + }) + ); + + this.account$ = combineLatest([ + this.stateService.activeAccount$, + this.stateService.accounts$, + ]).pipe( + map(([activeAccount, accounts]) => { + return accounts[activeAccount]?.profile; + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/sm/layout/layout.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html similarity index 73% rename from bitwarden_license/bit-web/src/app/sm/layout/layout.component.html rename to bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html index 417272a29f..5d15daf96b 100644 --- a/bitwarden_license/bit-web/src/app/sm/layout/layout.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html @@ -1,8 +1,8 @@
-
diff --git a/bitwarden_license/bit-web/src/app/sm/layout/layout.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/sm/layout/layout.component.ts rename to bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts new file mode 100644 index 0000000000..419de00875 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts @@ -0,0 +1,68 @@ +import { Component } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { Meta, Story, moduleMetadata } from "@storybook/angular"; + +import { NavigationModule, IconModule } from "@bitwarden/components"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/tests/preloaded-english-i18n.module"; + +import { LayoutComponent } from "./layout.component"; +import { NavigationComponent } from "./navigation.component"; + +@Component({ + selector: "story-content", + template: `

Content

`, +}) +class StoryContentComponent {} + +export default { + title: "Web/Layout", + component: LayoutComponent, + decorators: [ + moduleMetadata({ + imports: [ + RouterModule.forRoot( + [ + { + path: "", + component: LayoutComponent, + children: [ + { + path: "", + redirectTo: "secrets", + pathMatch: "full", + }, + { + path: "secrets", + component: StoryContentComponent, + data: { + title: "secrets", + searchTitle: "searchSecrets", + }, + }, + { + outlet: "sidebar", + path: "", + component: NavigationComponent, + }, + ], + }, + ], + { useHash: true } + ), + IconModule, + NavigationModule, + PreloadedEnglishI18nModule, + ], + declarations: [LayoutComponent, NavigationComponent, StoryContentComponent], + }), + ], +} as Meta; + +const Template: Story = (args) => ({ + props: args, + template: ` + + `, +}); + +export const Default = Template.bind({}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html new file mode 100644 index 0000000000..c68b751eb8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts new file mode 100644 index 0000000000..40bfb71726 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts @@ -0,0 +1,11 @@ +import { Component } from "@angular/core"; + +import { SecretsManagerLogo } from "./secrets-manager-logo"; + +@Component({ + selector: "sm-navigation", + templateUrl: "./navigation.component.html", +}) +export class NavigationComponent { + protected readonly logo = SecretsManagerLogo; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.html new file mode 100644 index 0000000000..67d54c90f9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.html @@ -0,0 +1,18 @@ + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.ts new file mode 100644 index 0000000000..44e206f9a0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/new-menu.component.ts @@ -0,0 +1,67 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { + ProjectDialogComponent, + ProjectOperation, +} from "../projects/dialog/project-dialog.component"; +import { + OperationType, + SecretDialogComponent, + SecretOperation, +} from "../secrets/dialog/secret-dialog.component"; +import { + ServiceAccountDialogComponent, + ServiceAccountOperation, +} from "../service-accounts/dialog/service-account-dialog.component"; + +@Component({ + selector: "sm-new-menu", + templateUrl: "./new-menu.component.html", +}) +export class NewMenuComponent implements OnInit, OnDestroy { + private organizationId: string; + private destroy$: Subject = new Subject(); + + constructor(private route: ActivatedRoute, private dialogService: DialogService) {} + + ngOnInit() { + this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { + this.organizationId = params.organizationId; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + openSecretDialog() { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + }, + }); + } + + openProjectDialog() { + this.dialogService.open(ProjectDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + }, + }); + } + + openServiceAccountDialog() { + this.dialogService.open(ServiceAccountDialogComponent, { + data: { + organizationId: this.organizationId, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.html new file mode 100644 index 0000000000..c2add8e479 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.html @@ -0,0 +1,16 @@ +
+
+ +

+ +

+

+ +

+
+
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.ts new file mode 100644 index 0000000000..bc516b5995 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/no-items.component.ts @@ -0,0 +1,11 @@ +import { Component } from "@angular/core"; + +import { Icons } from "@bitwarden/components"; + +@Component({ + selector: "sm-no-items", + templateUrl: "./no-items.component.html", +}) +export class NoItemsComponent { + protected icon = Icons.Search; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html new file mode 100644 index 0000000000..9b678157ca --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.ts new file mode 100644 index 0000000000..97cb46ee3c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.ts @@ -0,0 +1,33 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, map, Observable } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; +import type { Organization } from "@bitwarden/common/models/domain/organization"; + +@Component({ + selector: "org-switcher", + templateUrl: "org-switcher.component.html", +}) +export class OrgSwitcherComponent { + protected organizations$: Observable = this.organizationService.organizations$; + protected activeOrganization$: Observable = combineLatest([ + this.route.paramMap, + this.organizationService.organizations$, + ]).pipe(map(([params, orgs]) => orgs.find((org) => org.id === params.get("organizationId")))); + + /** + * Is `true` if the expanded content is visible + */ + @Input() + open = false; + @Output() + openChange = new EventEmitter(); + + constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {} + + protected toggle(event?: MouseEvent) { + event?.stopPropagation(); + this.open = !this.open; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/secrets-manager-logo.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/secrets-manager-logo.ts new file mode 100644 index 0000000000..890daea952 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/secrets-manager-logo.ts @@ -0,0 +1,7 @@ +import { svgIcon } from "@bitwarden/components"; + +export const SecretsManagerLogo = svgIcon` + + + +`; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project-list.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project-list.view.ts new file mode 100644 index 0000000000..fd747264b4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project-list.view.ts @@ -0,0 +1,9 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class ProjectListView implements View { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project.view.ts new file mode 100644 index 0000000000..b2b75dc30b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/project.view.ts @@ -0,0 +1,9 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class ProjectView implements View { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-list.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-list.view.ts new file mode 100644 index 0000000000..9b61c51cd6 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-list.view.ts @@ -0,0 +1,12 @@ +import { View } from "@bitwarden/common/models/view/view"; + +import { SecretProjectView } from "./secret-project.view"; + +export class SecretListView implements View { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; + projects: SecretProjectView[]; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-project.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-project.view.ts new file mode 100644 index 0000000000..6c7eaee657 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret-project.view.ts @@ -0,0 +1,6 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class SecretProjectView implements View { + id: string; + name: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret.view.ts new file mode 100644 index 0000000000..7c949c9dde --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/secret.view.ts @@ -0,0 +1,11 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class SecretView implements View { + id: string; + organizationId: string; + name: string; + value: string; + note: string; + creationDate: string; + revisionDate: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts new file mode 100644 index 0000000000..638245bb15 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts @@ -0,0 +1,9 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class ServiceAccountView implements View { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview-routing.module.ts new file mode 100644 index 0000000000..e074eed765 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +import { OverviewComponent } from "./overview.component"; + +const routes: Routes = [ + { + path: "", + component: OverviewComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class OverviewRoutingModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html new file mode 100644 index 0000000000..4192b1713c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -0,0 +1 @@ + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts new file mode 100644 index 0000000000..308459eb6e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "sm-overview", + templateUrl: "./overview.component.html", +}) +export class OverviewComponent {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts new file mode 100644 index 0000000000..2e1cee28c2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; + +import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; + +import { OverviewRoutingModule } from "./overview-routing.module"; +import { OverviewComponent } from "./overview.component"; + +@NgModule({ + imports: [SecretsManagerSharedModule, OverviewRoutingModule], + declarations: [OverviewComponent], + providers: [], +}) +export class OverviewModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html new file mode 100644 index 0000000000..98a964bd5e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html @@ -0,0 +1,35 @@ +
+ + + {{ title | i18n }} + + + {{ data.projects[0].name }} + + + {{ data.projects.length }} + {{ "projects" | i18n }} + + + + +
+ + {{ dialogContent }} + + + {{ dialogConfirmationLabel }} + + +
+ +
+ + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts new file mode 100644 index 0000000000..cb175ee756 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts @@ -0,0 +1,122 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { + FormControl, + FormGroup, + ValidationErrors, + ValidatorFn, + AbstractControl, +} from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { DialogService } from "@bitwarden/components"; + +import { + BulkOperationStatus, + BulkStatusDetails, + BulkStatusDialogComponent, +} from "../../layout/dialogs/bulk-status-dialog.component"; +import { ProjectListView } from "../../models/view/project-list.view"; +import { ProjectService } from "../project.service"; + +export interface ProjectDeleteOperation { + projects: ProjectListView[]; +} + +@Component({ + selector: "sm-project-delete-dialog", + templateUrl: "./project-delete-dialog.component.html", +}) +export class ProjectDeleteDialogComponent implements OnInit { + formGroup = new FormGroup({ + confirmDelete: new FormControl("", [this.matchConfirmationMessageValidator()]), + }); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: ProjectDeleteOperation, + private projectService: ProjectService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private dialogService: DialogService + ) {} + + ngOnInit(): void { + if (!(this.data.projects?.length >= 1)) { + this.dialogRef.close(); + throw new Error( + "The project delete dialog was not called with the appropriate operation values." + ); + } + } + + get title() { + return this.data.projects.length === 1 ? "deleteProject" : "deleteProjects"; + } + + get dialogContent() { + return this.data.projects.length === 1 + ? this.i18nService.t("deleteProjectDialogMessage", this.data.projects[0].name) + : this.i18nService.t("deleteProjectsDialogMessage"); + } + + get dialogConfirmationLabel() { + return this.i18nService.t("deleteProjectInputLabel", this.dialogConfirmationMessage); + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + await this.delete(); + this.dialogRef.close(); + }; + + async delete() { + const bulkResponses = await this.projectService.delete(this.data.projects); + + if (bulkResponses.find((response) => response.errorMessage)) { + this.openBulkStatusDialog(bulkResponses.filter((response) => response.errorMessage)); + return; + } + + const message = this.data.projects.length === 1 ? "deleteProjectToast" : "deleteProjectsToast"; + this.platformUtilsService.showToast("success", null, this.i18nService.t(message)); + } + + openBulkStatusDialog(bulkStatusResults: BulkOperationStatus[]) { + this.dialogService.open(BulkStatusDialogComponent, { + data: { + title: "deleteProjects", + subTitle: "projects", + columnTitle: "projectName", + message: "bulkDeleteProjectsErrorMessage", + details: bulkStatusResults, + }, + }); + } + + private get dialogConfirmationMessage() { + return this.data.projects?.length === 1 + ? this.i18nService.t("deleteProjectConfirmMessage", this.data.projects[0].name) + : this.i18nService.t("deleteProjectsConfirmMessage", this.data.projects?.length.toString()); + } + + private matchConfirmationMessageValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (this.dialogConfirmationMessage.toLowerCase() == control.value.toLowerCase()) { + return null; + } else { + return { + confirmationDoesntMatchError: { + message: this.i18nService.t("smConfirmationRequired"), + }, + }; + } + }; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html new file mode 100644 index 0000000000..8973245227 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html @@ -0,0 +1,22 @@ +
+ + {{ title | i18n }} + +
+ +
+ + {{ "projectName" | i18n }} + + +
+
+ + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts new file mode 100644 index 0000000000..b9c563d682 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -0,0 +1,97 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +import { ProjectView } from "../../models/view/project.view"; +import { ProjectService } from "../../projects/project.service"; + +export enum OperationType { + Add, + Edit, +} + +export interface ProjectOperation { + organizationId: string; + operation: OperationType; + projectId?: string; +} + +@Component({ + selector: "sm-project-dialog", + templateUrl: "./project-dialog.component.html", +}) +export class ProjectDialogComponent implements OnInit { + protected formGroup = new FormGroup({ + name: new FormControl("", [Validators.required]), + }); + protected loading = false; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) private data: ProjectOperation, + private projectService: ProjectService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private router: Router + ) {} + + async ngOnInit() { + if (this.data.operation === OperationType.Edit && this.data.projectId) { + await this.loadData(); + } else if (this.data.operation !== OperationType.Add) { + this.dialogRef.close(); + throw new Error(`The project dialog was not called with the appropriate operation values.`); + } + } + + async loadData() { + this.loading = true; + const project: ProjectView = await this.projectService.getByProjectId(this.data.projectId); + this.loading = false; + this.formGroup.setValue({ name: project.name }); + } + + get title() { + return this.data.operation === OperationType.Add ? "newProject" : "editProject"; + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const projectView = this.getProjectView(); + if (this.data.operation === OperationType.Add) { + const newProject = await this.createProject(projectView); + this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]); + } else { + projectView.id = this.data.projectId; + await this.updateProject(projectView); + } + this.dialogRef.close(); + }; + + private async createProject(projectView: ProjectView) { + const newProject = await this.projectService.create(this.data.organizationId, projectView); + this.platformUtilsService.showToast("success", null, this.i18nService.t("projectCreated")); + return newProject; + } + + private async updateProject(projectView: ProjectView) { + await this.projectService.update(this.data.organizationId, projectView); + this.platformUtilsService.showToast("success", null, this.i18nService.t("projectSaved")); + } + + private getProjectView() { + const projectView = new ProjectView(); + projectView.organizationId = this.data.organizationId; + projectView.name = this.formGroup.value.name; + return projectView; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts new file mode 100644 index 0000000000..994c12e927 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts @@ -0,0 +1,5 @@ +import { EncString } from "@bitwarden/common/models/domain/enc-string"; + +export class ProjectRequest { + name: EncString; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project-list-item.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project-list-item.response.ts new file mode 100644 index 0000000000..dce0f7ffbb --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project-list-item.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class ProjectListItemResponse extends BaseResponse { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.name = this.getResponseProperty("Name"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project.response.ts new file mode 100644 index 0000000000..7603a35600 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/responses/project.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class ProjectResponse extends BaseResponse { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.name = this.getResponseProperty("Name"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts new file mode 100644 index 0000000000..59bf9e1d96 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { BulkOperationStatus } from "../layout/dialogs/bulk-status-dialog.component"; +import { ProjectListView } from "../models/view/project-list.view"; +import { ProjectView } from "../models/view/project.view"; + +import { ProjectRequest } from "./models/requests/project.request"; +import { ProjectListItemResponse } from "./models/responses/project-list-item.response"; +import { ProjectResponse } from "./models/responses/project.response"; + +@Injectable({ + providedIn: "root", +}) +export class ProjectService { + protected _project = new Subject(); + project$ = this._project.asObservable(); + + constructor( + private cryptoService: CryptoService, + private apiService: ApiService, + private encryptService: EncryptService + ) {} + + async getByProjectId(projectId: string): Promise { + const r = await this.apiService.send("GET", "/projects/" + projectId, null, true, true); + const projectResponse = new ProjectResponse(r); + return await this.createProjectView(projectResponse); + } + + async getProjects(organizationId: string): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/projects", + null, + true, + true + ); + const results = new ListResponse(r, ProjectListItemResponse); + return await this.createProjectsListView(organizationId, results.data); + } + + async create(organizationId: string, projectView: ProjectView): Promise { + const request = await this.getProjectRequest(organizationId, projectView); + const r = await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/projects", + request, + true, + true + ); + + const project = await this.createProjectView(new ProjectResponse(r)); + this._project.next(project); + return project; + } + + async update(organizationId: string, projectView: ProjectView) { + const request = await this.getProjectRequest(organizationId, projectView); + const r = await this.apiService.send("PUT", "/projects/" + projectView.id, request, true, true); + this._project.next(await this.createProjectView(new ProjectResponse(r))); + } + + async delete(projects: ProjectListView[]): Promise { + const projectIds = projects.map((project) => project.id); + const r = await this.apiService.send("POST", "/projects/delete", projectIds, true, true); + this._project.next(null); + return r.data.map((element: { id: string; error: string }) => { + const bulkOperationStatus = new BulkOperationStatus(); + bulkOperationStatus.id = element.id; + bulkOperationStatus.name = projects.find((project) => project.id == element.id).name; + bulkOperationStatus.errorMessage = element.error; + return bulkOperationStatus; + }); + } + + private async getOrganizationKey(organizationId: string): Promise { + return await this.cryptoService.getOrgKey(organizationId); + } + + private async getProjectRequest( + organizationId: string, + projectView: ProjectView + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + const request = new ProjectRequest(); + request.name = await this.encryptService.encrypt(projectView.name, orgKey); + + return request; + } + + private async createProjectView(projectResponse: ProjectResponse): Promise { + const orgKey = await this.getOrganizationKey(projectResponse.organizationId); + + const projectView = new ProjectView(); + projectView.id = projectResponse.id; + projectView.organizationId = projectResponse.organizationId; + projectView.creationDate = projectResponse.creationDate; + projectView.revisionDate = projectResponse.revisionDate; + projectView.name = await this.encryptService.decryptToUtf8( + new EncString(projectResponse.name), + orgKey + ); + + return projectView; + } + + private async createProjectsListView( + organizationId: string, + projects: ProjectListItemResponse[] + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + return await Promise.all( + projects.map(async (s: ProjectListItemResponse) => { + const projectListView = new ProjectListView(); + projectListView.id = s.id; + projectListView.organizationId = s.organizationId; + projectListView.name = await this.encryptService.decryptToUtf8( + new EncString(s.name), + orgKey + ); + projectListView.creationDate = s.creationDate; + projectListView.revisionDate = s.revisionDate; + return projectListView; + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html new file mode 100644 index 0000000000..e47c3cc234 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html @@ -0,0 +1,20 @@ + +
+ +
+ +
+ + +
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts new file mode 100644 index 0000000000..1fa7774fa2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -0,0 +1,78 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { SecretListView } from "../../models/view/secret-list.view"; +import { + SecretDeleteDialogComponent, + SecretDeleteOperation, +} from "../../secrets/dialog/secret-delete.component"; +import { + OperationType, + SecretDialogComponent, + SecretOperation, +} from "../../secrets/dialog/secret-dialog.component"; +import { SecretService } from "../../secrets/secret.service"; + +@Component({ + selector: "sm-project-secrets", + templateUrl: "./project-secrets.component.html", +}) +export class ProjectSecretsComponent { + secrets$: Observable; + + private organizationId: string; + private projectId: string; + + constructor( + private route: ActivatedRoute, + private secretService: SecretService, + private dialogService: DialogService + ) {} + + ngOnInit() { + this.secrets$ = this.secretService.secret$.pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(async ([_, params]) => { + this.organizationId = params.organizationId; + this.projectId = params.projectId; + return await this.getSecretsByProject(); + }) + ); + } + + private async getSecretsByProject(): Promise { + return await this.secretService.getSecretsByProject(this.organizationId, this.projectId); + } + + openEditSecret(secretId: string) { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Edit, + secretId: secretId, + }, + }); + } + + openDeleteSecret(secretIds: string[]) { + this.dialogService.open(SecretDeleteDialogComponent, { + data: { + secretIds: secretIds, + }, + }); + } + + openNewSecretDialog() { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + projectId: this.projectId, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.html new file mode 100644 index 0000000000..f9c39aaed1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.html @@ -0,0 +1,8 @@ + + + + Secrets + Access + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts new file mode 100644 index 0000000000..8720b30c01 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -0,0 +1,24 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Observable, switchMap } from "rxjs"; + +import { ProjectView } from "../../models/view/project.view"; +import { ProjectService } from "../project.service"; + +@Component({ + selector: "sm-project", + templateUrl: "./project.component.html", +}) +export class ProjectComponent implements OnInit { + project: Observable; + + constructor(private route: ActivatedRoute, private projectService: ProjectService) {} + + ngOnInit(): void { + this.project = this.route.params.pipe( + switchMap((params) => { + return this.projectService.getByProjectId(params.projectId); + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.html new file mode 100644 index 0000000000..ea18d38650 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.html @@ -0,0 +1,93 @@ +
+ +
+ + + {{ "projectsNoItemsTitle" | i18n }} + {{ "projectsNoItemsMessage" | i18n }} + + + + + + + + + + + {{ "name" | i18n }} + {{ "lastEdited" | i18n }} + + + + + + + + + + + + + + + {{ project.name }} + + {{ project.revisionDate | date: "medium" }} + + + + + + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts new file mode 100644 index 0000000000..97bf650c2b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts @@ -0,0 +1,65 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { ProjectListView } from "../../models/view/project-list.view"; + +@Component({ + selector: "sm-projects-list", + templateUrl: "./projects-list.component.html", +}) +export class ProjectsListComponent implements OnDestroy { + @Input() + get projects(): ProjectListView[] { + return this._projects; + } + set projects(projects: ProjectListView[]) { + this.selection.clear(); + this._projects = projects; + } + private _projects: ProjectListView[]; + + @Output() editProjectEvent = new EventEmitter(); + @Output() viewProjectEvent = new EventEmitter(); + @Output() deleteProjectEvent = new EventEmitter(); + @Output() onProjectCheckedEvent = new EventEmitter(); + @Output() newProjectEvent = new EventEmitter(); + @Output() importSecretsEvent = new EventEmitter(); + + private destroy$: Subject = new Subject(); + + selection = new SelectionModel(true, []); + + constructor() { + this.selection.changed + .pipe(takeUntil(this.destroy$)) + .subscribe((_) => this.onProjectCheckedEvent.emit(this.selection.selected)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.projects.length; + return numSelected === numRows; + } + + toggleAll() { + this.isAllSelected() + ? this.selection.clear() + : this.selection.select(...this.projects.map((s) => s.id)); + } + + deleteProject(projectId: string) { + this.deleteProjectEvent.emit(this.projects.filter((p) => p.id == projectId)); + } + + bulkDeleteProjects() { + this.deleteProjectEvent.emit( + this.projects.filter((project) => this.selection.isSelected(project.id)) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts new file mode 100644 index 0000000000..c0d6642e9d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +import { ProjectSecretsComponent } from "./project/project-secrets.component"; +import { ProjectComponent } from "./project/project.component"; +import { ProjectsComponent } from "./projects/projects.component"; + +const routes: Routes = [ + { + path: "", + component: ProjectsComponent, + }, + { + path: ":projectId", + component: ProjectComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "secrets", + }, + { + path: "secrets", + component: ProjectSecretsComponent, + }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ProjectsRoutingModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects.module.ts new file mode 100644 index 0000000000..56c9840cdf --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from "@angular/core"; + +import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; + +import { ProjectDeleteDialogComponent } from "./dialog/project-delete-dialog.component"; +import { ProjectDialogComponent } from "./dialog/project-dialog.component"; +import { ProjectSecretsComponent } from "./project/project-secrets.component"; +import { ProjectComponent } from "./project/project.component"; +import { ProjectsListComponent } from "./projects-list/projects-list.component"; +import { ProjectsRoutingModule } from "./projects-routing.module"; +import { ProjectsComponent } from "./projects/projects.component"; + +@NgModule({ + imports: [SecretsManagerSharedModule, ProjectsRoutingModule], + declarations: [ + ProjectsComponent, + ProjectsListComponent, + ProjectDialogComponent, + ProjectDeleteDialogComponent, + ProjectComponent, + ProjectSecretsComponent, + ], + providers: [], +}) +export class ProjectsModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html new file mode 100644 index 0000000000..040df33abc --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html @@ -0,0 +1,8 @@ + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts new file mode 100644 index 0000000000..1d36212171 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -0,0 +1,75 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { ProjectListView } from "../../models/view/project-list.view"; +import { + ProjectDeleteDialogComponent, + ProjectDeleteOperation, +} from "../dialog/project-delete-dialog.component"; +import { + OperationType, + ProjectDialogComponent, + ProjectOperation, +} from "../dialog/project-dialog.component"; +import { ProjectService } from "../project.service"; + +@Component({ + selector: "sm-projects", + templateUrl: "./projects.component.html", +}) +export class ProjectsComponent implements OnInit { + projects$: Observable; + + private organizationId: string; + + constructor( + private route: ActivatedRoute, + private projectService: ProjectService, + private dialogService: DialogService + ) {} + + ngOnInit() { + this.projects$ = this.projectService.project$.pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(async ([_, params]) => { + this.organizationId = params.organizationId; + return await this.getProjects(); + }) + ); + } + + private async getProjects(): Promise { + return await this.projectService.getProjects(this.organizationId); + } + + openEditProject(projectId: string) { + this.dialogService.open(ProjectDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Edit, + projectId: projectId, + }, + }); + } + + openNewProjectDialog() { + this.dialogService.open(ProjectDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + }, + }); + } + + openDeleteProjectDialog(event: ProjectListView[]) { + this.dialogService.open(ProjectDeleteDialogComponent, { + data: { + projects: event, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/sm/sm.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets-manager.module.ts similarity index 57% rename from bitwarden_license/bit-web/src/app/sm/sm.module.ts rename to bitwarden_license/bit-web/src/app/secrets-manager/secrets-manager.module.ts index b87b961caf..6510e95ab2 100644 --- a/bitwarden_license/bit-web/src/app/sm/sm.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets-manager.module.ts @@ -4,12 +4,14 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { LayoutComponent } from "./layout/layout.component"; import { NavigationComponent } from "./layout/navigation.component"; +import { OrgSwitcherComponent } from "./layout/org-switcher.component"; +import { SecretsManagerSharedModule } from "./shared/sm-shared.module"; import { SecretsManagerRoutingModule } from "./sm-routing.module"; import { SMGuard } from "./sm.guard"; @NgModule({ - imports: [SharedModule, SecretsManagerRoutingModule], - declarations: [LayoutComponent, NavigationComponent], + imports: [SharedModule, SecretsManagerSharedModule, SecretsManagerRoutingModule], + declarations: [LayoutComponent, NavigationComponent, OrgSwitcherComponent], providers: [SMGuard], }) export class SecretsManagerModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.html new file mode 100644 index 0000000000..301c36a901 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.html @@ -0,0 +1,17 @@ + + {{ title | i18n }} + +
+ {{ "softDeleteSecretWarning" | i18n }} +
+ {{ "deleteItemConfirmation" | i18n }} +
+
+ + +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts new file mode 100644 index 0000000000..2056718781 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts @@ -0,0 +1,37 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +import { SecretService } from "../secret.service"; + +export interface SecretDeleteOperation { + secretIds: string[]; +} + +@Component({ + selector: "sm-secret-delete-dialog", + templateUrl: "./secret-delete.component.html", +}) +export class SecretDeleteDialogComponent { + constructor( + public dialogRef: DialogRef, + private secretService: SecretService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + @Inject(DIALOG_DATA) public data: SecretDeleteOperation + ) {} + + get title() { + return this.data.secretIds.length === 1 ? "deleteSecret" : "deleteSecrets"; + } + + delete = async () => { + await this.secretService.delete(this.data.secretIds); + this.dialogRef.close(); + const message = + this.data.secretIds.length === 1 ? "softDeleteSuccessToast" : "softDeletesSuccessToast"; + this.platformUtilsService.showToast("success", null, this.i18nService.t(message)); + }; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html new file mode 100644 index 0000000000..6f88cb69c1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html @@ -0,0 +1,38 @@ +
+ + {{ title | i18n }} +
+
+ +
+ + +
+ + {{ "name" | i18n }} + + + + {{ "value" | i18n }} + + +
+ + {{ "notes" | i18n }} + + +
+ + +
+
+
+ + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts new file mode 100644 index 0000000000..2f7a64b555 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -0,0 +1,98 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +import { SecretView } from "../../models/view/secret.view"; +import { SecretService } from "../secret.service"; + +export enum OperationType { + Add, + Edit, +} + +export interface SecretOperation { + organizationId: string; + operation: OperationType; + projectId?: string; + secretId?: string; +} + +@Component({ + selector: "sm-secret-dialog", + templateUrl: "./secret-dialog.component.html", +}) +export class SecretDialogComponent implements OnInit { + protected formGroup = new FormGroup({ + name: new FormControl("", [Validators.required]), + value: new FormControl("", [Validators.required]), + notes: new FormControl(""), + }); + protected loading = false; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) private data: SecretOperation, + private secretService: SecretService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService + ) {} + + async ngOnInit() { + if (this.data.operation === OperationType.Edit && this.data.secretId) { + await this.loadData(); + } else if (this.data.operation !== OperationType.Add) { + this.dialogRef.close(); + throw new Error(`The secret dialog was not called with the appropriate operation values.`); + } + } + + async loadData() { + this.loading = true; + const secret: SecretView = await this.secretService.getBySecretId(this.data.secretId); + this.loading = false; + this.formGroup.setValue({ name: secret.name, value: secret.value, notes: secret.note }); + } + + get title() { + return this.data.operation === OperationType.Add ? "newSecret" : "editSecret"; + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const secretView = this.getSecretView(); + if (this.data.operation === OperationType.Add) { + await this.createSecret(secretView, this.data.projectId); + } else { + secretView.id = this.data.secretId; + await this.updateSecret(secretView); + } + this.dialogRef.close(); + }; + + private async createSecret(secretView: SecretView, projectId?: string) { + await this.secretService.create(this.data.organizationId, secretView, projectId); + this.platformUtilsService.showToast("success", null, this.i18nService.t("secretCreated")); + } + + private async updateSecret(secretView: SecretView) { + await this.secretService.update(this.data.organizationId, secretView); + this.platformUtilsService.showToast("success", null, this.i18nService.t("secretEdited")); + } + + private getSecretView() { + const secretView = new SecretView(); + secretView.organizationId = this.data.organizationId; + secretView.name = this.formGroup.value.name; + secretView.value = this.formGroup.value.value; + secretView.note = this.formGroup.value.notes; + return secretView; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/requests/secret.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/requests/secret.request.ts new file mode 100644 index 0000000000..d84641341d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/requests/secret.request.ts @@ -0,0 +1,6 @@ +export class SecretRequest { + key: string; + value: string; + note: string; + projectId?: string; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-list-item.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-list-item.response.ts new file mode 100644 index 0000000000..3819f95870 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-list-item.response.ts @@ -0,0 +1,20 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class SecretListItemResponse extends BaseResponse { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; + projects: string[]; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.name = this.getResponseProperty("Key"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + this.projects = this.getResponseProperty("projects"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-project.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-project.response.ts new file mode 100644 index 0000000000..1336b8111e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-project.response.ts @@ -0,0 +1,12 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class SecretProjectResponse extends BaseResponse { + id: string; + name: string; + + constructor(response: any) { + super(response); + this.name = this.getResponseProperty("Name"); + this.id = this.getResponseProperty("Id"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-with-projects-list.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-with-projects-list.response.ts new file mode 100644 index 0000000000..2b1d22b31f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret-with-projects-list.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +import { SecretListItemResponse } from "./secret-list-item.response"; +import { SecretProjectResponse } from "./secret-project.response"; + +export class SecretWithProjectsListResponse extends BaseResponse { + secrets: SecretListItemResponse[]; + projects: SecretProjectResponse[]; + + constructor(response: any) { + super(response); + const secrets = this.getResponseProperty("secrets"); + const projects = this.getResponseProperty("projects"); + this.projects = + projects == null ? null : projects.map((k: any) => new SecretProjectResponse(k)); + this.secrets = secrets == null ? [] : secrets.map((dr: any) => new SecretListItemResponse(dr)); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret.response.ts new file mode 100644 index 0000000000..c873ac6206 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/responses/secret.response.ts @@ -0,0 +1,22 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class SecretResponse extends BaseResponse { + id: string; + organizationId: string; + name: string; + value: string; + note: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.name = this.getResponseProperty("Key"); + this.value = this.getResponseProperty("Value"); + this.note = this.getResponseProperty("Note"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts new file mode 100644 index 0000000000..1b87652ee6 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts @@ -0,0 +1,193 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; + +import { SecretListView } from "../models/view/secret-list.view"; +import { SecretProjectView } from "../models/view/secret-project.view"; +import { SecretView } from "../models/view/secret.view"; + +import { SecretRequest } from "./requests/secret.request"; +import { SecretListItemResponse } from "./responses/secret-list-item.response"; +import { SecretProjectResponse } from "./responses/secret-project.response"; +import { SecretWithProjectsListResponse } from "./responses/secret-with-projects-list.response"; +import { SecretResponse } from "./responses/secret.response"; + +@Injectable({ + providedIn: "root", +}) +export class SecretService { + protected _secret: Subject = new Subject(); + + secret$ = this._secret.asObservable(); + + constructor( + private cryptoService: CryptoService, + private apiService: ApiService, + private encryptService: EncryptService + ) {} + + async getBySecretId(secretId: string): Promise { + const r = await this.apiService.send("GET", "/secrets/" + secretId, null, true, true); + const secretResponse = new SecretResponse(r); + return await this.createSecretView(secretResponse); + } + + async getSecrets(organizationId: string): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/secrets", + null, + true, + true + ); + + const results = new SecretWithProjectsListResponse(r); + return await this.createSecretsListView(organizationId, results); + } + + async getSecretsByProject(organizationId: string, projectId: string): Promise { + const r = await this.apiService.send( + "GET", + "/projects/" + projectId + "/secrets", + null, + true, + true + ); + + const results = new SecretWithProjectsListResponse(r); + return await this.createSecretsListView(organizationId, results); + } + + async create(organizationId: string, secretView: SecretView, projectId?: string) { + const request = await this.getSecretRequest(organizationId, secretView, projectId); + const r = await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/secrets", + request, + true, + true + ); + this._secret.next(await this.createSecretView(new SecretResponse(r))); + } + + async update(organizationId: string, secretView: SecretView) { + const request = await this.getSecretRequest(organizationId, secretView); + const r = await this.apiService.send("PUT", "/secrets/" + secretView.id, request, true, true); + this._secret.next(await this.createSecretView(new SecretResponse(r))); + } + + async delete(secretIds: string[]) { + const r = await this.apiService.send("POST", "/secrets/delete", secretIds, true, true); + + const responseErrors: string[] = []; + r.data.forEach((element: { error: string }) => { + if (element.error) { + responseErrors.push(element.error); + } + }); + + // TODO waiting to hear back on how to display multiple errors. + // for now send as a list of strings to be displayed in toast. + if (responseErrors?.length >= 1) { + throw new Error(responseErrors.join(",")); + } + + this._secret.next(null); + } + + private async getOrganizationKey(organizationId: string): Promise { + return await this.cryptoService.getOrgKey(organizationId); + } + + private async getSecretRequest( + organizationId: string, + secretView: SecretView, + projectId?: string + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + const request = new SecretRequest(); + const [key, value, note] = await Promise.all([ + this.encryptService.encrypt(secretView.name, orgKey), + this.encryptService.encrypt(secretView.value, orgKey), + this.encryptService.encrypt(secretView.note, orgKey), + ]); + request.key = key.encryptedString; + request.value = value.encryptedString; + request.note = note.encryptedString; + request.projectId = projectId; + return request; + } + + private async createSecretView(secretResponse: SecretResponse): Promise { + const orgKey = await this.getOrganizationKey(secretResponse.organizationId); + + const secretView = new SecretView(); + secretView.id = secretResponse.id; + secretView.organizationId = secretResponse.organizationId; + secretView.creationDate = secretResponse.creationDate; + secretView.revisionDate = secretResponse.revisionDate; + + const [name, value, note] = await Promise.all([ + this.encryptService.decryptToUtf8(new EncString(secretResponse.name), orgKey), + this.encryptService.decryptToUtf8(new EncString(secretResponse.value), orgKey), + this.encryptService.decryptToUtf8(new EncString(secretResponse.note), orgKey), + ]); + secretView.name = name; + secretView.value = value; + secretView.note = note; + + return secretView; + } + + private async createSecretsListView( + organizationId: string, + secrets: SecretWithProjectsListResponse + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + + const projectsMappedToSecretsView = this.decryptProjectsMappedToSecrets( + orgKey, + secrets.projects + ); + + return await Promise.all( + secrets.secrets.map(async (s: SecretListItemResponse) => { + const secretListView = new SecretListView(); + secretListView.id = s.id; + secretListView.organizationId = s.organizationId; + secretListView.name = await this.encryptService.decryptToUtf8( + new EncString(s.name), + orgKey + ); + secretListView.creationDate = s.creationDate; + secretListView.revisionDate = s.revisionDate; + secretListView.projects = (await projectsMappedToSecretsView).filter((p) => + s.projects.includes(p.id) + ); + return secretListView; + }) + ); + } + + private async decryptProjectsMappedToSecrets( + orgKey: SymmetricCryptoKey, + projects: SecretProjectResponse[] + ): Promise { + return await Promise.all( + projects.map(async (s: SecretProjectResponse) => { + const projectsMappedToSecretView = new SecretProjectView(); + projectsMappedToSecretView.id = s.id; + projectsMappedToSecretView.name = await this.encryptService.decryptToUtf8( + new EncString(s.name), + orgKey + ); + return projectsMappedToSecretView; + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/sm/secrets/secrets-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets-routing.module.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/sm/secrets/secrets-routing.module.ts rename to bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets-routing.module.ts diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html new file mode 100644 index 0000000000..f5ebd11ebc --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html @@ -0,0 +1,7 @@ + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts new file mode 100644 index 0000000000..367416ea89 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { SecretListView } from "../models/view/secret-list.view"; + +import { + SecretDeleteDialogComponent, + SecretDeleteOperation, +} from "./dialog/secret-delete.component"; +import { + OperationType, + SecretDialogComponent, + SecretOperation, +} from "./dialog/secret-dialog.component"; +import { SecretService } from "./secret.service"; + +@Component({ + selector: "sm-secrets", + templateUrl: "./secrets.component.html", +}) +export class SecretsComponent implements OnInit { + secrets$: Observable; + + private organizationId: string; + + constructor( + private route: ActivatedRoute, + private secretService: SecretService, + private dialogService: DialogService + ) {} + + ngOnInit() { + this.secrets$ = this.secretService.secret$.pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(async ([_, params]) => { + this.organizationId = params.organizationId; + return await this.getSecrets(); + }) + ); + } + + private async getSecrets(): Promise { + return await this.secretService.getSecrets(this.organizationId); + } + + openEditSecret(secretId: string) { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Edit, + secretId: secretId, + }, + }); + } + + openDeleteSecret(secretIds: string[]) { + this.dialogService.open(SecretDeleteDialogComponent, { + data: { + secretIds: secretIds, + }, + }); + } + + openNewSecretDialog() { + this.dialogService.open(SecretDialogComponent, { + data: { + organizationId: this.organizationId, + operation: OperationType.Add, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.module.ts new file mode 100644 index 0000000000..a137d9fdd5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from "@angular/core"; + +import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; + +import { SecretDeleteDialogComponent } from "./dialog/secret-delete.component"; +import { SecretDialogComponent } from "./dialog/secret-dialog.component"; +import { SecretsRoutingModule } from "./secrets-routing.module"; +import { SecretsComponent } from "./secrets.component"; + +@NgModule({ + imports: [SecretsManagerSharedModule, SecretsRoutingModule], + declarations: [SecretsComponent, SecretDialogComponent, SecretDeleteDialogComponent], + providers: [], +}) +export class SecretsModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html new file mode 100644 index 0000000000..dd53ccd08b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html @@ -0,0 +1,77 @@ +
+ +
+ + + {{ "accessTokensNoItemsTitle" | i18n }} + {{ "accessTokensNoItemsDesc" | i18n }} + + + + + + + + + + {{ "name" | i18n }} + {{ "permissions" | i18n }} + {{ "expires" | i18n }} + {{ "lastEdited" | i18n }} + + + + + + + + + + + {{ token.name }} + {{ permission(token) | i18n }} + + {{ token.expireAt === null ? ("never" | i18n) : (token.expireAt | date: "medium") }} + + {{ token.revisionDate | date: "medium" }} + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts new file mode 100644 index 0000000000..483835b2bb --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts @@ -0,0 +1,40 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { AccessTokenView } from "../models/view/access-token.view"; + +@Component({ + selector: "sm-access-list", + templateUrl: "./access-list.component.html", +}) +export class AccessListComponent { + @Input() + get tokens(): AccessTokenView[] { + return this._tokens; + } + set tokens(secrets: AccessTokenView[]) { + this.selection.clear(); + this._tokens = secrets; + } + private _tokens: AccessTokenView[]; + + @Output() newAccessTokenEvent = new EventEmitter(); + + protected selection = new SelectionModel(true, []); + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.tokens.length; + return numSelected === numRows; + } + + toggleAll() { + this.isAllSelected() + ? this.selection.clear() + : this.selection.select(...this.tokens.map((s) => s.id)); + } + + protected permission(token: AccessTokenView) { + return "canRead"; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.html new file mode 100644 index 0000000000..3122d13ae2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.html @@ -0,0 +1,4 @@ + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts new file mode 100644 index 0000000000..8729dcb015 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts @@ -0,0 +1,61 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { ServiceAccountView } from "../../models/view/service-account.view"; +import { AccessTokenView } from "../models/view/access-token.view"; + +import { AccessService } from "./access.service"; +import { + AccessTokenOperation, + AccessTokenCreateDialogComponent, +} from "./dialogs/access-token-create-dialog.component"; + +@Component({ + selector: "sm-access-tokens", + templateUrl: "./access-tokens.component.html", +}) +export class AccessTokenComponent implements OnInit { + accessTokens$: Observable; + + private serviceAccountId: string; + private organizationId: string; + + constructor( + private route: ActivatedRoute, + private accessService: AccessService, + private dialogService: DialogService + ) {} + + ngOnInit() { + this.accessTokens$ = this.accessService.accessToken$.pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(async ([_, params]) => { + this.organizationId = params.organizationId; + this.serviceAccountId = params.serviceAccountId; + return await this.getAccessTokens(); + }) + ); + } + + private async getAccessTokens(): Promise { + return await this.accessService.getAccessTokens(this.organizationId, this.serviceAccountId); + } + + async openNewAccessTokenDialog() { + // TODO once service account names are implemented in service account contents page pass in here. + const serviceAccountView = new ServiceAccountView(); + serviceAccountView.id = this.serviceAccountId; + serviceAccountView.name = "placeholder"; + + this.dialogService.open(AccessTokenCreateDialogComponent, { + data: { + organizationId: this.organizationId, + serviceAccountView: serviceAccountView, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts new file mode 100644 index 0000000000..a5ad5a0690 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { AccessTokenRequest } from "../models/requests/access-token.request"; +import { AccessTokenCreationResponse } from "../models/responses/access-token-creation.response"; +import { AccessTokenResponse } from "../models/responses/access-tokens.response"; +import { AccessTokenView } from "../models/view/access-token.view"; + +@Injectable({ + providedIn: "root", +}) +export class AccessService { + private readonly _accessTokenVersion = "0"; + protected _accessToken: Subject = new Subject(); + + accessToken$ = this._accessToken.asObservable(); + + constructor( + private cryptoService: CryptoService, + private apiService: ApiService, + private cryptoFunctionService: CryptoFunctionService, + private encryptService: EncryptService + ) {} + + async getAccessTokens( + organizationId: string, + serviceAccountId: string + ): Promise { + const r = await this.apiService.send( + "GET", + "/service-accounts/" + serviceAccountId + "/access-tokens", + null, + true, + true + ); + const results = new ListResponse(r, AccessTokenResponse); + + return await this.createAccessTokenViews(organizationId, results.data); + } + + async createAccessToken( + organizationId: string, + serviceAccountId: string, + accessTokenView: AccessTokenView + ): Promise { + const keyMaterial = await this.cryptoFunctionService.randomBytes(16); + const key = await this.cryptoFunctionService.hkdf( + keyMaterial, + "bitwarden-accesstoken", + "sm-access-token", + 64, + "sha256" + ); + const encryptionKey = new SymmetricCryptoKey(key); + + const request = await this.createAccessTokenRequest( + organizationId, + encryptionKey, + accessTokenView + ); + const r = await this.apiService.send( + "POST", + "/service-accounts/" + serviceAccountId + "/access-tokens", + request, + true, + true + ); + const result = new AccessTokenCreationResponse(r); + this._accessToken.next(null); + const b64Key = Utils.fromBufferToB64(keyMaterial); + return `${this._accessTokenVersion}.${result.id}.${result.clientSecret}:${b64Key}`; + } + + private async createAccessTokenRequest( + organizationId: string, + encryptionKey: SymmetricCryptoKey, + accessTokenView: AccessTokenView + ): Promise { + const organizationKey = await this.getOrganizationKey(organizationId); + const accessTokenRequest = new AccessTokenRequest(); + const [name, encryptedPayload, key] = await Promise.all([ + await this.encryptService.encrypt(accessTokenView.name, organizationKey), + await this.encryptService.encrypt( + JSON.stringify({ encryptionKey: organizationKey.keyB64 }), + encryptionKey + ), + await this.encryptService.encrypt(encryptionKey.keyB64, organizationKey), + ]); + + accessTokenRequest.name = name; + accessTokenRequest.encryptedPayload = encryptedPayload; + accessTokenRequest.key = key; + accessTokenRequest.expireAt = accessTokenView.expireAt; + return accessTokenRequest; + } + + private async getOrganizationKey(organizationId: string): Promise { + return await this.cryptoService.getOrgKey(organizationId); + } + + private async createAccessTokenViews( + organizationId: string, + accessTokenResponses: AccessTokenResponse[] + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + return await Promise.all( + accessTokenResponses.map(async (s) => { + const view = new AccessTokenView(); + view.id = s.id; + view.name = await this.encryptService.decryptToUtf8(new EncString(s.name), orgKey); + view.scopes = s.scopes; + view.expireAt = s.expireAt ? new Date(s.expireAt) : null; + view.creationDate = new Date(s.creationDate); + view.revisionDate = new Date(s.revisionDate); + return view; + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html new file mode 100644 index 0000000000..45a5c2ee7b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html @@ -0,0 +1,44 @@ +
+ + + {{ "createAccessToken" | i18n }} + + {{ data.serviceAccountView.name }} + + + +
+ + {{ "name" | i18n }} + + +
+ + {{ "permissions" | i18n }} + + + + {{ "accessTokenPermissionsBetaNotification" | i18n }} + +
+ +
+ +
+ + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts new file mode 100644 index 0000000000..93ef185f6a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts @@ -0,0 +1,85 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { DialogService } from "@bitwarden/components"; + +import { ServiceAccountView } from "../../../models/view/service-account.view"; +import { AccessTokenView } from "../../models/view/access-token.view"; +import { AccessService } from "../access.service"; + +import { AccessTokenDetails, AccessTokenDialogComponent } from "./access-token-dialog.component"; + +export interface AccessTokenOperation { + organizationId: string; + serviceAccountView: ServiceAccountView; +} + +@Component({ + selector: "sm-access-token-create-dialog", + templateUrl: "./access-token-create-dialog.component.html", +}) +export class AccessTokenCreateDialogComponent implements OnInit { + protected formGroup = new FormGroup({ + name: new FormControl("", [Validators.required, Validators.maxLength(80)]), + expirationDateControl: new FormControl(null), + }); + protected loading = false; + + expirationDayOptions = [7, 30, 60]; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: AccessTokenOperation, + private dialogService: DialogService, + private accessService: AccessService + ) {} + + async ngOnInit() { + if ( + !this.data.organizationId || + !this.data.serviceAccountView?.id || + !this.data.serviceAccountView?.name + ) { + this.dialogRef.close(); + throw new Error( + `The access token create dialog was not called with the appropriate operation values.` + ); + } + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } + const accessTokenView = new AccessTokenView(); + accessTokenView.name = this.formGroup.value.name; + accessTokenView.expireAt = this.formGroup.value.expirationDateControl; + const accessToken = await this.accessService.createAccessToken( + this.data.organizationId, + this.data.serviceAccountView.id, + accessTokenView + ); + this.openAccessTokenDialog( + this.data.serviceAccountView.name, + accessToken, + accessTokenView.expireAt + ); + this.dialogRef.close(); + }; + + private openAccessTokenDialog( + serviceAccountName: string, + accessToken: string, + expirationDate?: Date + ) { + this.dialogService.open(AccessTokenDialogComponent, { + data: { + subTitle: serviceAccountName, + expirationDate: expirationDate, + accessToken: accessToken, + }, + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.html new file mode 100644 index 0000000000..90e67ac865 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.html @@ -0,0 +1,30 @@ + + + {{ "createAccessToken" | i18n }} + + {{ data.subTitle }} + + + +
+ + {{ "downloadAccessToken" | i18n }}
+ {{ "expiresOnAccessToken" | i18n }} + {{ data.expirationDate === null ? ("never" | i18n) : (data.expirationDate | date: "medium") }} +
+ + + {{ "accessToken" | i18n }} + + + {{ "expiresOnAccessToken" | i18n }} + {{ data.expirationDate === null ? ("never" | i18n) : (data.expirationDate | date: "medium") }} +
+ +
+ +
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts new file mode 100644 index 0000000000..620598fba7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts @@ -0,0 +1,44 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +export interface AccessTokenDetails { + subTitle: string; + expirationDate?: Date; + accessToken: string; +} + +@Component({ + selector: "sm-access-token-dialog", + templateUrl: "./access-token-dialog.component.html", +}) +export class AccessTokenDialogComponent implements OnInit { + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: AccessTokenDetails, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService + ) { + this.dialogRef.disableClose = true; + } + + ngOnInit(): void { + // TODO remove null checks once strictNullChecks in TypeScript is turned on. + if (!this.data.subTitle || !this.data.accessToken) { + this.dialogRef.close(); + throw new Error("The access token dialog was not called with the appropriate values."); + } + } + + copyAccessToken(): void { + this.platformUtilsService.copyToClipboard(this.data.accessToken); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("accessTokenCreatedAndCopied") + ); + this.dialogRef.close(); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.html new file mode 100644 index 0000000000..f1aaf2cc31 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.html @@ -0,0 +1,21 @@ + + + {{ "expires" | i18n }} + + + + {{ "expirationDate" | i18n }} + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts new file mode 100644 index 0000000000..132494ef47 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts @@ -0,0 +1,114 @@ +import { DatePipe } from "@angular/common"; +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { + AbstractControl, + ControlValueAccessor, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators, +} from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; + +@Component({ + selector: "sm-expiration-options", + templateUrl: "./expiration-options.component.html", + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: ExpirationOptionsComponent, + }, + { + provide: NG_VALIDATORS, + multi: true, + useExisting: ExpirationOptionsComponent, + }, + ], +}) +export class ExpirationOptionsComponent + implements ControlValueAccessor, Validator, OnInit, OnDestroy +{ + private destroy$ = new Subject(); + + @Input() expirationDayOptions: number[]; + + @Input() set touched(val: boolean) { + if (val) { + this.form.markAllAsTouched(); + } + } + + currentDate = new Date(); + + protected form = new FormGroup({ + expires: new FormControl("never", [Validators.required]), + expireDateTime: new FormControl("", [Validators.required]), + }); + + constructor(private datePipe: DatePipe) {} + + async ngOnInit() { + this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { + this._onChange(this.getExpiresDate()); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private _onChange = (_value: Date | null): void => undefined; + registerOnChange(fn: (value: Date | null) => void): void { + this._onChange = fn; + } + + onTouched = (): void => undefined; + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + validate(control: AbstractControl): ValidationErrors { + if ( + (this.form.value.expires == "custom" && this.form.value.expireDateTime) || + this.form.value.expires !== "custom" + ) { + return null; + } + return { + required: true, + }; + } + + writeValue(value: Date | null): void { + if (value == null) { + this.form.setValue({ expires: "never", expireDateTime: null }); + } + if (value) { + this.form.setValue({ + expires: "custom", + expireDateTime: this.datePipe.transform(value, "YYYY-MM-ddThh:mm"), + }); + } + } + + setDisabledState?(isDisabled: boolean): void { + isDisabled ? this.form.disable() : this.form.enable(); + } + + private getExpiresDate(): Date | null { + if (this.form.value.expires == "never") { + return null; + } + if (this.form.value.expires == "custom") { + return new Date(this.form.value.expireDateTime); + } + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() + Number(this.form.value.expires)); + return currentDate; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html new file mode 100644 index 0000000000..9c41d83999 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html @@ -0,0 +1,50 @@ +
+ + {{ "newServiceAccount" | i18n }} +
+ + {{ "serviceAccountName" | i18n }} + + +

{{ "smAccess" | i18n }}

+ + + {{ "newSaSelectAccess" | i18n }} + + + + + + + {{ "projectCommaSecret" | i18n }} + {{ "permissions" | i18n }} + + + + + + example + example + + + +
+
+ + +
+
+
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts new file mode 100644 index 0000000000..642136bfa8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -0,0 +1,69 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; + +import { ProjectListView } from "../../models/view/project-list.view"; +import { SecretListView } from "../../models/view/secret-list.view"; +import { ServiceAccountView } from "../../models/view/service-account.view"; +import { ProjectService } from "../../projects/project.service"; +import { SecretService } from "../../secrets/secret.service"; +import { ServiceAccountService } from "../service-account.service"; + +export interface ServiceAccountOperation { + organizationId: string; +} + +@Component({ + selector: "sm-service-account-dialog", + templateUrl: "./service-account-dialog.component.html", +}) +export class ServiceAccountDialogComponent implements OnInit { + projects: ProjectListView[]; + secrets: SecretListView[]; + + formGroup = new FormGroup({ + name: new FormControl("", [Validators.required]), + }); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) private data: ServiceAccountOperation, + private serviceAccountService: ServiceAccountService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private projectService: ProjectService, + private secretService: SecretService + ) {} + + async ngOnInit() { + this.projects = await this.projectService.getProjects(this.data.organizationId); + this.secrets = await this.secretService.getSecrets(this.data.organizationId); + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + const serviceAccountView = this.getServiceAccountView(); + await this.serviceAccountService.create(this.data.organizationId, serviceAccountView); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("serviceAccountCreated") + ); + this.dialogRef.close(); + }; + + private getServiceAccountView() { + const serviceAccountView = new ServiceAccountView(); + serviceAccountView.organizationId = this.data.organizationId; + serviceAccountView.name = this.formGroup.value.name; + return serviceAccountView; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts new file mode 100644 index 0000000000..c570d3d205 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts @@ -0,0 +1,8 @@ +import { EncString } from "@bitwarden/common/models/domain/enc-string"; + +export class AccessTokenRequest { + name: EncString; + encryptedPayload: EncString; + key: EncString; + expireAt: Date; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts new file mode 100644 index 0000000000..8c62324a78 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts @@ -0,0 +1,5 @@ +import { EncString } from "@bitwarden/common/models/domain/enc-string"; + +export class ServiceAccountRequest { + name: EncString; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-token-creation.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-token-creation.response.ts new file mode 100644 index 0000000000..b5b1c27b6f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-token-creation.response.ts @@ -0,0 +1,20 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class AccessTokenCreationResponse extends BaseResponse { + id: string; + name: string; + clientSecret: string; + expireAt?: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.name = this.getResponseProperty("Name"); + this.clientSecret = this.getResponseProperty("ClientSecret"); + this.expireAt = this.getResponseProperty("ExpireAt"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-tokens.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-tokens.response.ts new file mode 100644 index 0000000000..4a3fc16d03 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/access-tokens.response.ts @@ -0,0 +1,20 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class AccessTokenResponse extends BaseResponse { + id: string; + name: string; + scopes: string[]; + expireAt?: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.name = this.getResponseProperty("Name"); + this.scopes = this.getResponseProperty("Scopes"); + this.expireAt = this.getResponseProperty("ExpireAt"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts new file mode 100644 index 0000000000..7ece18f7d8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class ServiceAccountResponse extends BaseResponse { + id: string; + organizationId: string; + name: string; + creationDate: string; + revisionDate: string; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.name = this.getResponseProperty("Name"); + this.creationDate = this.getResponseProperty("CreationDate"); + this.revisionDate = this.getResponseProperty("RevisionDate"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/view/access-token.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/view/access-token.view.ts new file mode 100644 index 0000000000..96faa40b8f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/view/access-token.view.ts @@ -0,0 +1,10 @@ +import { View } from "@bitwarden/common/models/view/view"; + +export class AccessTokenView implements View { + id: string; + name: string; + scopes: string[]; + expireAt?: Date; + creationDate: Date; + revisionDate: Date; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html new file mode 100644 index 0000000000..c36c831597 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html @@ -0,0 +1,9 @@ + + + + {{ "secrets" | i18n }} + {{ "people" | i18n }} + {{ "accessTokens" | i18n }} + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts new file mode 100644 index 0000000000..f355ede8e4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "sm-service-account", + templateUrl: "./service-account.component.html", +}) +export class ServiceAccountComponent {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts new file mode 100644 index 0000000000..4870ac12f2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +import { ServiceAccountView } from "../models/view/service-account.view"; + +import { ServiceAccountRequest } from "./models/requests/service-account.request"; +import { ServiceAccountResponse } from "./models/responses/service-account.response"; + +@Injectable({ + providedIn: "root", +}) +export class ServiceAccountService { + protected _serviceAccount: Subject = new Subject(); + + serviceAccount$ = this._serviceAccount.asObservable(); + + constructor( + private cryptoService: CryptoService, + private apiService: ApiService, + private encryptService: EncryptService + ) {} + + async getServiceAccounts(organizationId: string): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/service-accounts", + null, + true, + true + ); + const results = new ListResponse(r, ServiceAccountResponse); + return await this.createServiceAccountViews(organizationId, results.data); + } + + async create(organizationId: string, serviceAccountView: ServiceAccountView) { + const orgKey = await this.getOrganizationKey(organizationId); + const request = await this.getServiceAccountRequest(orgKey, serviceAccountView); + const r = await this.apiService.send( + "POST", + "/organizations/" + organizationId + "/service-accounts", + request, + true, + true + ); + this._serviceAccount.next( + await this.createServiceAccountView(orgKey, new ServiceAccountResponse(r)) + ); + } + + private async getOrganizationKey(organizationId: string): Promise { + return await this.cryptoService.getOrgKey(organizationId); + } + + private async getServiceAccountRequest( + organizationKey: SymmetricCryptoKey, + serviceAccountView: ServiceAccountView + ) { + const request = new ServiceAccountRequest(); + request.name = await this.encryptService.encrypt(serviceAccountView.name, organizationKey); + return request; + } + + private async createServiceAccountView( + organizationKey: SymmetricCryptoKey, + serviceAccountResponse: ServiceAccountResponse + ): Promise { + const serviceAccountView = new ServiceAccountView(); + serviceAccountView.id = serviceAccountResponse.id; + serviceAccountView.organizationId = serviceAccountResponse.organizationId; + serviceAccountView.creationDate = serviceAccountResponse.creationDate; + serviceAccountView.revisionDate = serviceAccountResponse.revisionDate; + serviceAccountView.name = await this.encryptService.decryptToUtf8( + new EncString(serviceAccountResponse.name), + organizationKey + ); + return serviceAccountView; + } + + private async createServiceAccountViews( + organizationId: string, + serviceAccountResponses: ServiceAccountResponse[] + ): Promise { + const orgKey = await this.getOrganizationKey(organizationId); + return await Promise.all( + serviceAccountResponses.map(async (s: ServiceAccountResponse) => { + return await this.createServiceAccountView(orgKey, s); + }) + ); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html new file mode 100644 index 0000000000..6badffdfc5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html @@ -0,0 +1,96 @@ +
+ +
+ + + {{ "serviceAccountsNoItemsTitle" | i18n }} + {{ "serviceAccountsNoItemsMessage" | i18n }} + + + + + + + + + + {{ "name" | i18n }} + {{ "secrets" | i18n }} + {{ "lastEdited" | i18n }} + + + + + + + + + + + + + + + + {{ serviceAccount.name }} + + + + + 0 + + {{ serviceAccount.revisionDate | date: "medium" }} + + + + + + + {{ "viewServiceAccount" | i18n }} + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts new file mode 100644 index 0000000000..174af95368 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts @@ -0,0 +1,58 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { ServiceAccountView } from "../models/view/service-account.view"; + +@Component({ + selector: "sm-service-accounts-list", + templateUrl: "./service-accounts-list.component.html", +}) +export class ServiceAccountsListComponent implements OnDestroy { + @Input() + get serviceAccounts(): ServiceAccountView[] { + return this._serviceAccounts; + } + set serviceAccounts(serviceAccounts: ServiceAccountView[]) { + this.selection.clear(); + this._serviceAccounts = serviceAccounts; + } + private _serviceAccounts: ServiceAccountView[]; + + @Output() newServiceAccountEvent = new EventEmitter(); + @Output() deleteServiceAccountsEvent = new EventEmitter(); + @Output() onServiceAccountCheckedEvent = new EventEmitter(); + + private destroy$: Subject = new Subject(); + + selection = new SelectionModel(true, []); + + constructor() { + this.selection.changed + .pipe(takeUntil(this.destroy$)) + .subscribe((_) => this.onServiceAccountCheckedEvent.emit(this.selection.selected)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.serviceAccounts.length; + return numSelected === numRows; + } + + toggleAll() { + this.isAllSelected() + ? this.selection.clear() + : this.selection.select(...this.serviceAccounts.map((s) => s.id)); + } + + bulkDeleteServiceAccounts() { + if (this.selection.selected.length >= 1) { + this.deleteServiceAccountsEvent.emit(this.selection.selected); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-routing.module.ts new file mode 100644 index 0000000000..1db2110fec --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-routing.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +import { AccessTokenComponent } from "./access/access-tokens.component"; +import { ServiceAccountComponent } from "./service-account.component"; +import { ServiceAccountsComponent } from "./service-accounts.component"; + +const routes: Routes = [ + { + path: "", + component: ServiceAccountsComponent, + }, + { + path: ":serviceAccountId", + component: ServiceAccountComponent, + children: [ + { + path: "", + pathMatch: "full", + redirectTo: "access", + }, + { + path: "access", + component: AccessTokenComponent, + }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ServiceAccountsRoutingModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html new file mode 100644 index 0000000000..27e6873d7b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.html @@ -0,0 +1,5 @@ + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts new file mode 100644 index 0000000000..5c24559c10 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { ServiceAccountView } from "../models/view/service-account.view"; + +import { + ServiceAccountDialogComponent, + ServiceAccountOperation, +} from "./dialog/service-account-dialog.component"; +import { ServiceAccountService } from "./service-account.service"; + +@Component({ + selector: "sm-service-accounts", + templateUrl: "./service-accounts.component.html", +}) +export class ServiceAccountsComponent implements OnInit { + serviceAccounts$: Observable; + + private organizationId: string; + + constructor( + private route: ActivatedRoute, + private dialogService: DialogService, + private serviceAccountService: ServiceAccountService + ) {} + + ngOnInit() { + this.serviceAccounts$ = this.serviceAccountService.serviceAccount$.pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(async ([_, params]) => { + this.organizationId = params.organizationId; + return await this.getServiceAccounts(); + }) + ); + } + + openNewServiceAccountDialog() { + this.dialogService.open(ServiceAccountDialogComponent, { + data: { + organizationId: this.organizationId, + }, + }); + } + + private async getServiceAccounts(): Promise { + return await this.serviceAccountService.getServiceAccounts(this.organizationId); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.module.ts new file mode 100644 index 0000000000..768f4e6667 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from "@angular/core"; + +import { SecretsManagerSharedModule } from "../shared/sm-shared.module"; + +import { AccessListComponent } from "./access/access-list.component"; +import { AccessTokenComponent } from "./access/access-tokens.component"; +import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component"; +import { AccessTokenDialogComponent } from "./access/dialogs/access-token-dialog.component"; +import { ExpirationOptionsComponent } from "./access/dialogs/expiration-options.component"; +import { ServiceAccountDialogComponent } from "./dialog/service-account-dialog.component"; +import { ServiceAccountComponent } from "./service-account.component"; +import { ServiceAccountsListComponent } from "./service-accounts-list.component"; +import { ServiceAccountsRoutingModule } from "./service-accounts-routing.module"; +import { ServiceAccountsComponent } from "./service-accounts.component"; + +@NgModule({ + imports: [SecretsManagerSharedModule, ServiceAccountsRoutingModule], + declarations: [ + AccessListComponent, + ExpirationOptionsComponent, + AccessTokenComponent, + AccessTokenCreateDialogComponent, + AccessTokenDialogComponent, + ServiceAccountComponent, + ServiceAccountDialogComponent, + ServiceAccountsComponent, + ServiceAccountsListComponent, + ], + providers: [], +}) +export class ServiceAccountsModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html new file mode 100644 index 0000000000..bd8adde3d2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html @@ -0,0 +1,115 @@ +
+ +
+ + + {{ "secretsNoItemsTitle" | i18n }} + {{ "secretsNoItemsMessage" | i18n }} + + + + + + + + + + + {{ "name" | i18n }} + {{ "projects" | i18n }} + {{ "lastEdited" | i18n }} + + + + + + + + + + + + + + {{ secret.name }} + + + {{ project.name }} + + + {{ secret.revisionDate | date: "medium" }} + + + + + + + + + + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts new file mode 100644 index 0000000000..daae75fe9d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts @@ -0,0 +1,63 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; + +import { SecretListView } from "../models/view/secret-list.view"; + +@Component({ + selector: "sm-secrets-list", + templateUrl: "./secrets-list.component.html", +}) +export class SecretsListComponent implements OnDestroy { + @Input() + get secrets(): SecretListView[] { + return this._secrets; + } + set secrets(secrets: SecretListView[]) { + this.selection.clear(); + this._secrets = secrets; + } + private _secrets: SecretListView[]; + + @Output() editSecretEvent = new EventEmitter(); + @Output() copySecretNameEvent = new EventEmitter(); + @Output() copySecretValueEvent = new EventEmitter(); + @Output() projectsEvent = new EventEmitter(); + @Output() onSecretCheckedEvent = new EventEmitter(); + @Output() deleteSecretsEvent = new EventEmitter(); + @Output() newSecretEvent = new EventEmitter(); + @Output() importSecretsEvent = new EventEmitter(); + + private destroy$: Subject = new Subject(); + + selection = new SelectionModel(true, []); + + constructor() { + this.selection.changed + .pipe(takeUntil(this.destroy$)) + .subscribe((_) => this.onSecretCheckedEvent.emit(this.selection.selected)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.secrets.length; + return numSelected === numRows; + } + + toggleAll() { + this.isAllSelected() + ? this.selection.clear() + : this.selection.select(...this.secrets.map((s) => s.id)); + } + + bulkDeleteSecrets() { + if (this.selection.selected.length >= 1) { + this.deleteSecretsEvent.emit(this.selection.selected); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts new file mode 100644 index 0000000000..1460d4082d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts @@ -0,0 +1,35 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { BulkStatusDialogComponent } from "../layout/dialogs/bulk-status-dialog.component"; +import { FilterComponent } from "../layout/filter.component"; +import { HeaderComponent } from "../layout/header.component"; +import { NewMenuComponent } from "../layout/new-menu.component"; +import { NoItemsComponent } from "../layout/no-items.component"; + +import { SecretsListComponent } from "./secrets-list.component"; + +@NgModule({ + imports: [SharedModule], + exports: [ + SharedModule, + BulkStatusDialogComponent, + FilterComponent, + HeaderComponent, + NewMenuComponent, + NoItemsComponent, + SecretsListComponent, + ], + declarations: [ + BulkStatusDialogComponent, + FilterComponent, + HeaderComponent, + NewMenuComponent, + NoItemsComponent, + SecretsListComponent, + ], + providers: [], + bootstrap: [], +}) +export class SecretsManagerSharedModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts new file mode 100644 index 0000000000..a54553e347 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts @@ -0,0 +1,67 @@ +import { NgModule } from "@angular/core"; +import { RouterModule, Routes } from "@angular/router"; + +import { Organization } from "@bitwarden/common/models/domain/organization"; +import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/organizations/guards/org-permissions.guard"; +import { buildFlaggedRoute } from "@bitwarden/web-vault/app/oss-routing.module"; + +import { LayoutComponent } from "./layout/layout.component"; +import { NavigationComponent } from "./layout/navigation.component"; +import { OverviewModule } from "./overview/overview.module"; +import { ProjectsModule } from "./projects/projects.module"; +import { SecretsModule } from "./secrets/secrets.module"; +import { ServiceAccountsModule } from "./service-accounts/service-accounts.module"; +import { SMGuard } from "./sm.guard"; + +const routes: Routes = [ + buildFlaggedRoute("secretsManager", { + path: ":organizationId", + component: LayoutComponent, + canActivate: [OrganizationPermissionsGuard, SMGuard], + data: { + organizationPermissions: (org: Organization) => org.canAccessSecretsManager, + }, + children: [ + { + path: "", + component: NavigationComponent, + outlet: "sidebar", + }, + { + path: "secrets", + loadChildren: () => SecretsModule, + data: { + title: "secrets", + searchTitle: "searchSecrets", + }, + }, + { + path: "projects", + loadChildren: () => ProjectsModule, + data: { + title: "projects", + searchTitle: "searchProjects", + }, + }, + { + path: "service-accounts", + loadChildren: () => ServiceAccountsModule, + data: { + title: "serviceAccounts", + searchTitle: "searchServiceAccounts", + }, + }, + { + path: "", + loadChildren: () => OverviewModule, + pathMatch: "full", + }, + ], + }), +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class SecretsManagerRoutingModule {} diff --git a/bitwarden_license/bit-web/src/app/sm/sm.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts similarity index 52% rename from bitwarden_license/bit-web/src/app/sm/sm.guard.ts rename to bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts index f9bc86f12b..b937c3ec07 100644 --- a/bitwarden_license/bit-web/src/app/sm/sm.guard.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts @@ -1,13 +1,10 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivate } from "@angular/router"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - @Injectable() export class SMGuard implements CanActivate { - constructor(private platformUtilsService: PlatformUtilsService) {} - async canActivate(route: ActivatedRouteSnapshot) { - return this.platformUtilsService.isDev(); + // TODO: Verify org + return true; } } diff --git a/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.html deleted file mode 100644 index b664d4fcaa..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.html +++ /dev/null @@ -1,13 +0,0 @@ -Bitwarden - - diff --git a/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.ts deleted file mode 100644 index 8814d8490d..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/layout/navigation.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "sm-navigation", - templateUrl: "./navigation.component.html", -}) -export class NavigationComponent {} diff --git a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.html b/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.html deleted file mode 100644 index e17c4fdbc1..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.html +++ /dev/null @@ -1 +0,0 @@ -

Secrets

diff --git a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.ts deleted file mode 100644 index 9b12584e52..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "sm-secrets", - templateUrl: "./secrets.component.html", -}) -export class SecretsComponent {} diff --git a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.module.ts b/bitwarden_license/bit-web/src/app/sm/secrets/secrets.module.ts deleted file mode 100644 index b98e2a8f7f..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/secrets/secrets.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { NgModule } from "@angular/core"; - -import { SecretsRoutingModule } from "./secrets-routing.module"; -import { SecretsComponent } from "./secrets.component"; - -@NgModule({ - imports: [CommonModule, SecretsRoutingModule], - declarations: [SecretsComponent], - providers: [], -}) -export class SecretsModule {} diff --git a/bitwarden_license/bit-web/src/app/sm/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/sm/sm-routing.module.ts deleted file mode 100644 index 040efdb16f..0000000000 --- a/bitwarden_license/bit-web/src/app/sm/sm-routing.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NgModule } from "@angular/core"; -import { RouterModule, Routes } from "@angular/router"; - -import { LayoutComponent } from "./layout/layout.component"; -import { NavigationComponent } from "./layout/navigation.component"; -import { SecretsModule } from "./secrets/secrets.module"; -import { SMGuard } from "./sm.guard"; - -const routes: Routes = [ - { - path: "", - component: LayoutComponent, - canActivate: [SMGuard], - children: [ - { - path: "", - component: NavigationComponent, - outlet: "sidebar", - }, - { - path: "secrets", - loadChildren: () => SecretsModule, - }, - { - path: "", - redirectTo: "secrets", - pathMatch: "full", - }, - ], - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class SecretsManagerRoutingModule {} diff --git a/libs/common/src/models/data/organization.data.ts b/libs/common/src/models/data/organization.data.ts index 85658d9764..feb8036ed6 100644 --- a/libs/common/src/models/data/organization.data.ts +++ b/libs/common/src/models/data/organization.data.ts @@ -22,6 +22,7 @@ export class OrganizationData { useScim: boolean; useCustomPermissions: boolean; useResetPassword: boolean; + useSecretsManager: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -63,6 +64,7 @@ export class OrganizationData { this.useScim = response.useScim; this.useCustomPermissions = response.useCustomPermissions; this.useResetPassword = response.useResetPassword; + this.useSecretsManager = response.useSecretsManager; this.selfHost = response.selfHost; this.usersGetPremium = response.usersGetPremium; this.seats = response.seats; diff --git a/libs/common/src/models/domain/organization.ts b/libs/common/src/models/domain/organization.ts index ef87b3dee1..b0fdfeb036 100644 --- a/libs/common/src/models/domain/organization.ts +++ b/libs/common/src/models/domain/organization.ts @@ -24,6 +24,7 @@ export class Organization { useScim: boolean; useCustomPermissions: boolean; useResetPassword: boolean; + useSecretsManager: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -69,6 +70,7 @@ export class Organization { this.useScim = obj.useScim; this.useCustomPermissions = obj.useCustomPermissions; this.useResetPassword = obj.useResetPassword; + this.useSecretsManager = obj.useSecretsManager; this.selfHost = obj.selfHost; this.usersGetPremium = obj.usersGetPremium; this.seats = obj.seats; @@ -206,6 +208,10 @@ export class Organization { return this.providerId != null || this.providerName != null; } + get canAccessSecretsManager() { + return this.useSecretsManager; + } + static fromJSON(json: Jsonify) { if (json == null) { return null; diff --git a/libs/common/src/models/response/profile-organization.response.ts b/libs/common/src/models/response/profile-organization.response.ts index 1913784829..562b71710e 100644 --- a/libs/common/src/models/response/profile-organization.response.ts +++ b/libs/common/src/models/response/profile-organization.response.ts @@ -20,6 +20,7 @@ export class ProfileOrganizationResponse extends BaseResponse { useScim: boolean; useCustomPermissions: boolean; useResetPassword: boolean; + useSecretsManager: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -62,6 +63,7 @@ export class ProfileOrganizationResponse extends BaseResponse { this.useScim = this.getResponseProperty("UseScim") ?? false; this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false; this.useResetPassword = this.getResponseProperty("UseResetPassword"); + this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); this.selfHost = this.getResponseProperty("SelfHost"); this.usersGetPremium = this.getResponseProperty("UsersGetPremium"); this.seats = this.getResponseProperty("Seats"); diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 9db2192c5b..bd1a13de86 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -7,7 +7,7 @@ type SizeTypes = "large" | "default" | "small"; const SizeClasses: Record = { large: ["tw-h-16", "tw-w-16"], - default: ["tw-h-12", "tw-w-12"], + default: ["tw-h-10", "tw-w-10"], small: ["tw-h-7", "tw-w-7"], }; diff --git a/libs/components/src/dialog/dialog.module.ts b/libs/components/src/dialog/dialog.module.ts index 3492a38f10..421ebded71 100644 --- a/libs/components/src/dialog/dialog.module.ts +++ b/libs/components/src/dialog/dialog.module.ts @@ -18,7 +18,7 @@ import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; DialogComponent, SimpleDialogComponent, ], - exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent], + exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent, DialogCloseDirective], providers: [DialogService], }) export class DialogModule {} diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index a457ed2365..f82be6cd6d 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -9,7 +9,7 @@ export class DialogComponent { @Input() dialogSize: "small" | "default" | "large" = "default"; private _disablePadding: boolean; - @Input() set disablePadding(value: boolean) { + @Input() set disablePadding(value: boolean | string) { this._disablePadding = coerceBooleanProperty(value); } get disablePadding() { diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index abae539557..86af709cda 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -5,6 +5,7 @@ export * from "./banner"; export * from "./button"; export * from "./callout"; export * from "./checkbox"; +export * from "./color-password"; export * from "./dialog"; export * from "./form-field"; export * from "./icon-button"; @@ -12,8 +13,8 @@ export * from "./icon"; export * from "./link"; export * from "./menu"; export * from "./multi-select"; -export * from "./tabs"; +export * from "./navigation"; export * from "./table"; +export * from "./tabs"; export * from "./toggle-group"; -export * from "./color-password"; export * from "./utils/i18n-mock.service"; diff --git a/libs/components/src/navigation/index.ts b/libs/components/src/navigation/index.ts new file mode 100644 index 0000000000..240b832ec7 --- /dev/null +++ b/libs/components/src/navigation/index.ts @@ -0,0 +1 @@ +export * from "./navigation.module"; diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts new file mode 100644 index 0000000000..ce9d74a65c --- /dev/null +++ b/libs/components/src/navigation/nav-base.component.ts @@ -0,0 +1,47 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +/** + * Base class used in `NavGroupComponent` and `NavItemComponent` + */ +@Directive() +export abstract class NavBaseComponent { + /** + * Text to display in main content + */ + @Input() text: string; + + /** + * `aria-label` for main content + */ + @Input() ariaLabel: string; + + /** + * Optional icon, e.g. `"bwi-collection"` + */ + @Input() icon: string; + + /** + * Route to be passed to internal `routerLink` + */ + @Input() route: string | any[]; + + /** + * If this item is used within a tree, set `variant` to `"tree"` + */ + @Input() variant: "default" | "tree" = "default"; + + /** + * Depth level when nested inside of a `'tree'` variant + */ + @Input() treeDepth = 0; + + /** + * If `true`, do not change styles when nav item is active. + */ + @Input() hideActiveStyles = false; + + /** + * Fires when main content is clicked + */ + @Output() mainContentClicked: EventEmitter = new EventEmitter(); +} diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html new file mode 100644 index 0000000000..4f77a18a37 --- /dev/null +++ b/libs/components/src/navigation/nav-divider.component.html @@ -0,0 +1 @@ +
diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts new file mode 100644 index 0000000000..e0c5cf98b7 --- /dev/null +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bit-nav-divider", + templateUrl: "./nav-divider.component.html", +}) +export class NavDividerComponent {} diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html new file mode 100644 index 0000000000..65da2dd9e0 --- /dev/null +++ b/libs/components/src/navigation/nav-group.component.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + +
+ +
diff --git a/libs/components/src/navigation/nav-group.component.ts b/libs/components/src/navigation/nav-group.component.ts new file mode 100644 index 0000000000..5829701c9f --- /dev/null +++ b/libs/components/src/navigation/nav-group.component.ts @@ -0,0 +1,62 @@ +import { + AfterContentInit, + Component, + ContentChildren, + EventEmitter, + Input, + Output, + QueryList, +} from "@angular/core"; + +import { NavBaseComponent } from "./nav-base.component"; +import { NavItemComponent } from "./nav-item.component"; + +@Component({ + selector: "bit-nav-group", + templateUrl: "./nav-group.component.html", +}) +export class NavGroupComponent extends NavBaseComponent implements AfterContentInit { + @ContentChildren(NavGroupComponent, { + descendants: true, + }) + nestedGroups!: QueryList; + + @ContentChildren(NavItemComponent, { + descendants: true, + }) + nestedItems!: QueryList; + + /** + * UID for `[attr.aria-controls]` + */ + protected contentId = Math.random().toString(36).substring(2); + + /** + * Is `true` if the expanded content is visible + */ + @Input() + open = false; + @Output() + openChange = new EventEmitter(); + + protected toggle(event?: MouseEvent) { + event?.stopPropagation(); + this.open = !this.open; + } + + /** + * - For any nested NavGroupComponents or NavItemComponents, increment the `treeDepth` by 1. + */ + private initNestedStyles() { + if (this.variant !== "tree") { + return; + } + [...this.nestedGroups, ...this.nestedItems].forEach((navGroupOrItem) => { + navGroupOrItem.treeDepth += 1; + }); + } + + ngAfterContentInit(): void { + this.initNestedStyles(); + } +} diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts new file mode 100644 index 0000000000..333adb0da0 --- /dev/null +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -0,0 +1,74 @@ +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; + +import { SharedModule } from "../shared/shared.module"; +import { I18nMockService } from "../utils/i18n-mock.service"; + +import { NavGroupComponent } from "./nav-group.component"; +import { NavigationModule } from "./navigation.module"; + +export default { + title: "Component Library/Nav/Nav Group", + component: NavGroupComponent, + decorators: [ + moduleMetadata({ + imports: [SharedModule, RouterTestingModule, NavigationModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + submenu: "submenu", + toggleCollapse: "toggle collapse", + }); + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642", + }, + }, +} as Meta; + +export const Default: Story = (args) => ({ + props: args, + template: ` + + + + + + + + + + + `, +}); + +export const Tree: Story = (args) => ({ + props: args, + template: ` + + + + + + + + + + + + + + + + `, +}); diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html new file mode 100644 index 0000000000..b6ce5a52f4 --- /dev/null +++ b/libs/components/src/navigation/nav-item.component.html @@ -0,0 +1,79 @@ +
+
+
+ +
+ +
+ +
+ + + + + + {{ text }} + + + + + + + + + + + + + + + + + +
+ +
+
+
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts new file mode 100644 index 0000000000..634aa7e763 --- /dev/null +++ b/libs/components/src/navigation/nav-item.component.ts @@ -0,0 +1,48 @@ +import { Component, HostListener } from "@angular/core"; +import { IsActiveMatchOptions } from "@angular/router"; +import { BehaviorSubject, map } from "rxjs"; + +import { NavBaseComponent } from "./nav-base.component"; + +@Component({ + selector: "bit-nav-item", + templateUrl: "./nav-item.component.html", +}) +export class NavItemComponent extends NavBaseComponent { + /** + * Is `true` if `to` matches the current route + */ + private _active = false; + protected setActive(isActive: boolean) { + this._active = isActive; + } + protected get showActiveStyles() { + return this._active && !this.hideActiveStyles; + } + protected readonly rlaOptions: IsActiveMatchOptions = { + paths: "exact", + queryParams: "exact", + fragment: "ignored", + matrixParams: "ignored", + }; + + /** + * The design spec calls for the an outline to wrap the entire element when the template's anchor/button has :focus-visible. + * Usually, we would use :focus-within for this. However, that matches when a child element has :focus instead of :focus-visible. + * + * Currently, the browser does not have a pseudo selector that combines these two, e.g. :focus-visible-within (WICG/focus-visible#151) + * To make our own :focus-visible-within functionality, we use event delegation on the host and manually check if the focus target (denoted with the .fvw class) matches :focus-visible. We then map that state to some styles, so the entire component can have an outline. + */ + protected focusVisibleWithin$ = new BehaviorSubject(false); + protected fvwStyles$ = this.focusVisibleWithin$.pipe( + map((value) => (value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-text-alt2" : "")) + ); + @HostListener("focusin", ["$event.target"]) + onFocusIn(target: HTMLElement) { + this.focusVisibleWithin$.next(target.matches(".fvw:focus-visible")); + } + @HostListener("focusout") + onFocusOut() { + this.focusVisibleWithin$.next(false); + } +} diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts new file mode 100644 index 0000000000..e1a7128922 --- /dev/null +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -0,0 +1,93 @@ +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { IconButtonModule } from "../icon-button"; + +import { NavItemComponent } from "./nav-item.component"; +import { NavigationModule } from "./navigation.module"; + +export default { + title: "Component Library/Nav/Nav Item", + component: NavItemComponent, + decorators: [ + moduleMetadata({ + declarations: [], + imports: [RouterTestingModule, IconButtonModule, NavigationModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4687%3A86642", + }, + }, +} as Meta; + +const Template: Story = (args: NavItemComponent) => ({ + props: args, + template: ` + + `, +}); + +export const Default = Template.bind({}); +Default.args = { + text: "Hello World", + icon: "bwi-filter", +}; + +export const WithoutIcon = Template.bind({}); +WithoutIcon.args = { + text: "Hello World", + icon: "", +}; + +export const WithoutRoute: Story = (args: NavItemComponent) => ({ + props: args, + template: ` + + `, +}); + +export const WithChildButtons: Story = (args: NavItemComponent) => ({ + props: args, + template: ` + + + + + + `, +}); + +export const MultipleItemsWithDivider: Story = (args: NavItemComponent) => ({ + props: args, + template: ` + + + + + + `, +}); diff --git a/libs/components/src/navigation/navigation.module.ts b/libs/components/src/navigation/navigation.module.ts new file mode 100644 index 0000000000..3685c1b935 --- /dev/null +++ b/libs/components/src/navigation/navigation.module.ts @@ -0,0 +1,18 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { IconButtonModule } from "../icon-button/icon-button.module"; +import { SharedModule } from "../shared/shared.module"; + +import { NavDividerComponent } from "./nav-divider.component"; +import { NavGroupComponent } from "./nav-group.component"; +import { NavItemComponent } from "./nav-item.component"; + +@NgModule({ + imports: [CommonModule, SharedModule, IconButtonModule, OverlayModule, RouterModule], + declarations: [NavDividerComponent, NavGroupComponent, NavItemComponent], + exports: [NavDividerComponent, NavGroupComponent, NavItemComponent], +}) +export class NavigationModule {} diff --git a/libs/components/src/stories/colors.stories.mdx b/libs/components/src/stories/colors.stories.mdx index bdcfc13c6b..f358a68f59 100644 --- a/libs/components/src/stories/colors.stories.mdx +++ b/libs/components/src/stories/colors.stories.mdx @@ -21,6 +21,8 @@ export const Table = (args) => ( {Row("background")} {Row("background-alt")} {Row("background-alt2")} + {Row("background-alt3")} + {Row("background-alt4")} {Row("primary-300")} diff --git a/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts b/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts index 4b9da81824..cec2bf947b 100644 --- a/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts +++ b/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts @@ -24,7 +24,7 @@ export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestr fragment: "ignored", }; - @Input() route: string; + @Input() route: string | any[]; @Input() disabled = false; @HostListener("keydown", ["$event"]) onKeyDown(event: KeyboardEvent) { diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 7acdb02ebf..f5d9febceb 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -4,6 +4,8 @@ --color-background: 255 255 255; --color-background-alt: 251 251 251; --color-background-alt2: 23 92 219; + --color-background-alt3: 18 82 163; + --color-background-alt4: 13 60 119; --color-primary-300: 103 149 232; --color-primary-500: 23 93 220; @@ -45,6 +47,8 @@ --color-background: 31 36 46; --color-background-alt: 22 28 38; --color-background-alt2: 47 52 61; + --color-background-alt3: 47 52 61; + --color-background-alt4: 16 18 21; --color-primary-300: 23 93 220; --color-primary-500: 106 153 240; diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 8b05c449c9..a700a3377d 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -56,6 +56,8 @@ module.exports = { DEFAULT: rgba("--color-background"), alt: rgba("--color-background-alt"), alt2: rgba("--color-background-alt2"), + alt3: rgba("--color-background-alt3"), + alt4: rgba("--color-background-alt4"), }, }, textColor: { @@ -83,6 +85,9 @@ module.exports = { "50vw": "50vw", "75vw": "75vw", }, + minWidth: { + 52: "13rem", + }, maxWidth: ({ theme }) => ({ ...theme("width"), "90vw": "90vw", diff --git a/tailwind.config.js b/tailwind.config.js index 3eadeab0c7..2101288993 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,6 +4,7 @@ const config = require("./libs/components/tailwind.config.base"); config.content = [ "./libs/components/src/**/*.{html,ts,mdx}", "./apps/web/src/**/*.{html,ts,mdx}", + "./bitwarden_license/bit-web/src/**/*.{html,ts,mdx}", "./.storybook/preview.js", ]; config.safelist = [ diff --git a/tsconfig.json b/tsconfig.json index d9189e23eb..9bcd88c880 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/angular/*": ["./libs/angular/src/*"], "@bitwarden/node/*": ["./libs/node/src/*"], - "@bitwarden/components": ["./libs/components/src"] + "@bitwarden/components": ["./libs/components/src"], + "@bitwarden/web-vault/*": ["./apps/web/src/*"] }, "plugins": [ { @@ -26,6 +27,6 @@ } ] }, - "include": ["apps/web/src/**/*", "libs/*/src/**/*"], + "include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"], "exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts"] }