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 }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
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 @@
+
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 @@
+
+ 0" class="float-right tw-mt-3 tw-items-center">
+
+
+
+
+
+
+
+
+
+
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 }}
+
+
+
+
+= 1">
+
+
+
+
+ |
+ {{ "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 @@
+
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 }}
+
+
+
+= 1">
+
+
+
+
+ |
+ {{ "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 @@
+
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 @@
+
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 }}
+
+
+
+= 1">
+
+
+
+
+ |
+ {{ "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 }}
+
+
+
+
+= 1">
+
+
+
+
+ |
+ {{ "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 @@
-
-
-
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"]
}